в резолв добавлена новая логика
This commit is contained in:
@@ -56,7 +56,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
/>
|
||||
)}
|
||||
{screen === 'resolve' && (
|
||||
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
|
||||
<ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
|
||||
)}
|
||||
</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 {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import * as React from 'react';
|
||||
import styles from './ResolveScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
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 { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import {
|
||||
@@ -18,17 +20,20 @@ import {
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
Select,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IResolveScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
sharePoint: SharePointListsClient;
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
@@ -46,6 +51,7 @@ interface IResolveFormState {
|
||||
projectId: string;
|
||||
projectLabel: string;
|
||||
status: RouteStepStatus;
|
||||
fieldValues: ResolveFieldValue[];
|
||||
}
|
||||
|
||||
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||
@@ -53,13 +59,19 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||
userEmail: '',
|
||||
projectId: '',
|
||||
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 [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 employees = usePortalEmployees(dictionary);
|
||||
@@ -74,16 +86,35 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, 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 => {
|
||||
if (!requestTypeId) {
|
||||
resolved.fail(texts.resolve.validationRequestType);
|
||||
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({
|
||||
requestTypeId: Number(requestTypeId),
|
||||
userEmail: userEmail.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 }))}
|
||||
placeholder={texts.resolve.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -145,6 +176,51 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
/>
|
||||
</Field>
|
||||
</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}>
|
||||
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
||||
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
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, но значение переживает перезагрузку страницы — хранится
|
||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||
* квота) не ломает форму — просто не персистит.
|
||||
*
|
||||
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
||||
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
||||
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
||||
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
||||
*/
|
||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const [value, setValue] = React.useState<T>(() => {
|
||||
try {
|
||||
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 {
|
||||
return initial;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
|
||||
import { RouteStepStatus } from './enums';
|
||||
|
||||
/** Значение поля списка SharePoint (field_name шага) для проверки условий шагов. */
|
||||
export interface ResolveFieldValue {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ResolveTemplateRouteQuery {
|
||||
requestTypeId: number;
|
||||
userEmail?: string;
|
||||
projectId?: string;
|
||||
status: RouteStepStatus;
|
||||
fieldValues?: ResolveFieldValue[];
|
||||
}
|
||||
|
||||
export interface ResolvedRouteStep {
|
||||
|
||||
@@ -61,8 +61,10 @@ export class ApprovalRouteApi {
|
||||
remove: (id: number): Promise<void> =>
|
||||
this.http.delete(`/api/template-routes/${id}`),
|
||||
|
||||
// POST, а не GET: запрос несёт список условий (fieldValues) переменной длины —
|
||||
// не ложится на плоскую query-строку (см. IHttpClient.QueryParams).
|
||||
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 = {
|
||||
|
||||
@@ -246,9 +246,16 @@ export const texts = {
|
||||
projectClear: 'Без проекта',
|
||||
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
||||
statusLabel: 'Статус',
|
||||
conditionsLabel: 'Условия (значения полей заявки)',
|
||||
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
|
||||
conditionFieldPlaceholder: 'Выберите столбец списка',
|
||||
conditionFieldLoading: 'Загрузка столбцов списка...',
|
||||
conditionValuePlaceholder: 'Значение для проверки',
|
||||
conditionAdd: 'Добавить условие',
|
||||
run: 'Проверить',
|
||||
running: 'Проверка...',
|
||||
validationRequestType: 'Выберите тип заявки.',
|
||||
validationConditionField: 'В каждом условии заполните и поле, и значение.',
|
||||
summary: (routeId: number, requiresControl: boolean): string =>
|
||||
`Маршрут #${routeId}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
|
||||
unresolved: (count: number): string =>
|
||||
|
||||
Reference in New Issue
Block a user