в резолв добавлена новая логика

This commit is contained in:
gorbenko.pavel
2026-07-31 15:34:45 +05:00
parent 8a0bb146e9
commit e5b4af63fd
7 changed files with 148 additions and 10 deletions
@@ -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 =>