в резолв добавлена новая логика
This commit is contained in:
@@ -56,7 +56,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{screen === 'resolve' && (
|
{screen === 'resolve' && (
|
||||||
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
|
<ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,37 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.conditions {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsHint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.runButtonRow {
|
.runButtonRow {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import * as React from 'react';
|
|||||||
import styles from './ResolveScreen.module.scss';
|
import styles from './ResolveScreen.module.scss';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { Project, RouteStepStatus } from '../../models';
|
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||||
|
import { Project, ResolveFieldValue, RouteStepStatus } from '../../models';
|
||||||
import { routeStepStatusLabels, texts } from '../../texts';
|
import { routeStepStatusLabels, texts } from '../../texts';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
import {
|
import {
|
||||||
@@ -18,17 +20,20 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Field,
|
Field,
|
||||||
FormGrid,
|
FormGrid,
|
||||||
|
IconButton,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Panel,
|
Panel,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
SearchableSelect,
|
SearchableSelect,
|
||||||
Select
|
Select,
|
||||||
|
TextInput
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
|
||||||
export interface IResolveScreenProps {
|
export interface IResolveScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
dictionary?: PortalDictionaryClient;
|
dictionary?: PortalDictionaryClient;
|
||||||
|
sharePoint: SharePointListsClient;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +51,7 @@ interface IResolveFormState {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectLabel: string;
|
projectLabel: string;
|
||||||
status: RouteStepStatus;
|
status: RouteStepStatus;
|
||||||
|
fieldValues: ResolveFieldValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||||
@@ -53,13 +59,19 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
|||||||
userEmail: '',
|
userEmail: '',
|
||||||
projectId: '',
|
projectId: '',
|
||||||
projectLabel: '',
|
projectLabel: '',
|
||||||
status: RouteStepStatus.Approval
|
status: RouteStepStatus.Approval,
|
||||||
|
fieldValues: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||||||
const { requestTypeId, userEmail, projectId, projectLabel, status } = form;
|
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
||||||
|
|
||||||
|
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||||
|
const hasListName = Boolean(requestType?.listName);
|
||||||
|
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
||||||
|
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||||
|
|
||||||
const resolved = useResolveRoute(api);
|
const resolved = useResolveRoute(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
@@ -74,16 +86,35 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
[dictionary]
|
[dictionary]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateFieldValue = (index: number, patch: Partial<ResolveFieldValue>): void => {
|
||||||
|
setForm({ ...form, fieldValues: fieldValues.map((fv, i) => (i === index ? { ...fv, ...patch } : fv)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFieldValue = (): void => {
|
||||||
|
setForm({ ...form, fieldValues: [...fieldValues, { fieldName: '', value: '' }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFieldValue = (index: number): void => {
|
||||||
|
setForm({ ...form, fieldValues: fieldValues.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
const run = (): void => {
|
const run = (): void => {
|
||||||
if (!requestTypeId) {
|
if (!requestTypeId) {
|
||||||
resolved.fail(texts.resolve.validationRequestType);
|
resolved.fail(texts.resolve.validationRequestType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const conditions = fieldValues.filter(fv => fv.fieldName.trim() || fv.value.trim());
|
||||||
|
if (conditions.some(fv => !fv.fieldName.trim() || !fv.value.trim())) {
|
||||||
|
resolved.fail(texts.resolve.validationConditionField);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resolved.resolve({
|
resolved.resolve({
|
||||||
requestTypeId: Number(requestTypeId),
|
requestTypeId: Number(requestTypeId),
|
||||||
userEmail: userEmail.trim() || undefined,
|
userEmail: userEmail.trim() || undefined,
|
||||||
projectId: projectId.trim() || undefined,
|
projectId: projectId.trim() || undefined,
|
||||||
status
|
status,
|
||||||
|
fieldValues: conditions.length > 0 ? conditions : undefined
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,7 +133,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
placeholder={texts.resolve.requestTypePlaceholder}
|
placeholder={texts.resolve.requestTypePlaceholder}
|
||||||
value={requestTypeId}
|
value={requestTypeId}
|
||||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
onChange={e => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
@@ -145,6 +176,51 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
|
|
||||||
|
<div className={styles.conditions}>
|
||||||
|
<div className={styles.conditionsHeader}>
|
||||||
|
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
|
||||||
|
{requestTypeId && !hasListName && (
|
||||||
|
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasListName && (
|
||||||
|
<>
|
||||||
|
{fieldValues.map((fv, index) => {
|
||||||
|
const field = sharePointFields.items.find(f => f.internalName === fv.fieldName);
|
||||||
|
const isNumeric = field ? isNumericFieldType(field.typeAsString) : false;
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
|
||||||
|
<div className={styles.conditionRow} key={index}>
|
||||||
|
<Select
|
||||||
|
options={fieldNameOptions}
|
||||||
|
value={fv.fieldName}
|
||||||
|
disabled={sharePointFields.isLoading}
|
||||||
|
placeholder={
|
||||||
|
sharePointFields.isLoading
|
||||||
|
? texts.resolve.conditionFieldLoading
|
||||||
|
: texts.resolve.conditionFieldPlaceholder
|
||||||
|
}
|
||||||
|
onChange={e => updateFieldValue(index, { fieldName: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
type={isNumeric ? 'number' : 'text'}
|
||||||
|
value={fv.value}
|
||||||
|
placeholder={texts.resolve.conditionValuePlaceholder}
|
||||||
|
onChange={e => updateFieldValue(index, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => removeFieldValue(index)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button variant="secondary" size="sm" icon="plus" onClick={addFieldValue}>
|
||||||
|
{texts.resolve.conditionAdd}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.runButtonRow}>
|
<div className={styles.runButtonRow}>
|
||||||
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
||||||
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||||
* квота) не ломает форму — просто не персистит.
|
* квота) не ломает форму — просто не персистит.
|
||||||
|
*
|
||||||
|
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
||||||
|
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
||||||
|
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
||||||
|
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
||||||
*/
|
*/
|
||||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
const [value, setValue] = React.useState<T>(() => {
|
const [value, setValue] = React.useState<T>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(key);
|
const raw = window.localStorage.getItem(key);
|
||||||
return raw ? (JSON.parse(raw) as T) : initial;
|
if (!raw) return initial;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as T;
|
||||||
|
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return initial;
|
return initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@
|
|||||||
|
|
||||||
import { RouteStepStatus } from './enums';
|
import { RouteStepStatus } from './enums';
|
||||||
|
|
||||||
|
/** Значение поля списка SharePoint (field_name шага) для проверки условий шагов. */
|
||||||
|
export interface ResolveFieldValue {
|
||||||
|
fieldName: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolveTemplateRouteQuery {
|
export interface ResolveTemplateRouteQuery {
|
||||||
requestTypeId: number;
|
requestTypeId: number;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
status: RouteStepStatus;
|
status: RouteStepStatus;
|
||||||
|
fieldValues?: ResolveFieldValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedRouteStep {
|
export interface ResolvedRouteStep {
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export class ApprovalRouteApi {
|
|||||||
remove: (id: number): Promise<void> =>
|
remove: (id: number): Promise<void> =>
|
||||||
this.http.delete(`/api/template-routes/${id}`),
|
this.http.delete(`/api/template-routes/${id}`),
|
||||||
|
|
||||||
|
// POST, а не GET: запрос несёт список условий (fieldValues) переменной длины —
|
||||||
|
// не ложится на плоскую query-строку (см. IHttpClient.QueryParams).
|
||||||
resolve: (query: ResolveTemplateRouteQuery): Promise<ResolveTemplateRouteResult> =>
|
resolve: (query: ResolveTemplateRouteQuery): Promise<ResolveTemplateRouteResult> =>
|
||||||
this.http.get<ResolveTemplateRouteResult>('/api/template-routes/resolve', { ...query })
|
this.http.post<ResolveTemplateRouteResult>('/api/template-routes/resolve', query)
|
||||||
};
|
};
|
||||||
|
|
||||||
public readonly templateRouteSteps = {
|
public readonly templateRouteSteps = {
|
||||||
|
|||||||
@@ -246,9 +246,16 @@ export const texts = {
|
|||||||
projectClear: 'Без проекта',
|
projectClear: 'Без проекта',
|
||||||
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
||||||
statusLabel: 'Статус',
|
statusLabel: 'Статус',
|
||||||
|
conditionsLabel: 'Условия (значения полей заявки)',
|
||||||
|
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
|
||||||
|
conditionFieldPlaceholder: 'Выберите столбец списка',
|
||||||
|
conditionFieldLoading: 'Загрузка столбцов списка...',
|
||||||
|
conditionValuePlaceholder: 'Значение для проверки',
|
||||||
|
conditionAdd: 'Добавить условие',
|
||||||
run: 'Проверить',
|
run: 'Проверить',
|
||||||
running: 'Проверка...',
|
running: 'Проверка...',
|
||||||
validationRequestType: 'Выберите тип заявки.',
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
|
validationConditionField: 'В каждом условии заполните и поле, и значение.',
|
||||||
summary: (routeId: number, requiresControl: boolean): string =>
|
summary: (routeId: number, requiresControl: boolean): string =>
|
||||||
`Маршрут #${routeId}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
|
`Маршрут #${routeId}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
|
||||||
unresolved: (count: number): string =>
|
unresolved: (count: number): string =>
|
||||||
|
|||||||
Reference in New Issue
Block a user