import * as React from 'react'; import tableStyles from '../ui/DataTable.module.scss'; import styles from './RouteStepsScreen.module.scss'; import { RouteStepFormModal } from './RouteStepFormModal'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { routeStepStatusLabels, texts } from '../../texts'; import { shortGuid } from '../../utils/guid'; import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor'; import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; import { reportError } from '../../utils/errors'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { ActionsCell, Alert, Badge, Breadcrumbs, Button, CellNote, ConfirmDialog, IconButton, PageHeader, PrimaryCell } from '../ui'; export interface IRouteStepsScreenProps { api: ApprovalRouteApi; route: TemplateRoute; /** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */ requestType?: RequestType; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; sharePoint: SharePointListsClient; onBack: () => void; } /** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */ type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean }; export const RouteStepsScreen: React.FC = ({ api, route, requestType, dictionary, sharePoint, onBack }) => { const steps = useRouteSteps(api, route.id); const employees = usePortalEmployees(dictionary); const portalDictionaries = usePortalDictionaries(dictionary); const [savedWarning, setSavedWarning] = React.useState(); const [reorderError, setReorderError] = React.useState(); const [reordering, setReordering] = React.useState(false); const [hoveredBlock, setHoveredBlock] = React.useState(undefined); const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`; const [editing, setEditing] = React.useState(); const [showForm, setShowForm] = React.useState(false); const deleteTarget = useDeleteTarget( item => api.templateRouteSteps.remove(route.id, item.id), steps.reload ); // Пока справочник не загрузился (или маршрут ссылается на удалённую запись), // показываем усечённый Guid — как в RoutesScreen. const companyName = route.companyId ? portalDictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId) : texts.routeSteps.anyCompany; const departmentName = route.departmentId ? portalDictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId) : texts.routeSteps.anyDepartment; const openCreate = (): void => { setEditing(undefined); setShowForm(true); }; const openEdit = (item: TemplateRouteStep): void => { setEditing(item); setShowForm(true); }; const handleSaved = (savedStep: TemplateRouteStep): void => { setSavedWarning(savedStep.warning ?? undefined); steps.reload(); }; // Параллельность у шагов шаблона кодируется тем же способом, что и у строк // заявки: равный порядковый номер у соседей (отдельного флага в БД нет — // см. utils/orderBlocks.ts и TemplateRouteStep.orderNo). markParallel/blockRanges // работают по полю order, поэтому orderNo продублирован в него. const withParallel = React.useMemo( () => markParallel(steps.items.map(s => ({ ...s, order: s.orderNo }))) as Row[], [steps.items] ); const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]); // Перестановка = пересчёт orderNo у затронутых шагов. Патчим только те, у кого // номер реально изменился: PATCH с одним orderNo сохраняет остальные поля // (см. PatchAsync на бэкенде — там `r.X ?? entity.X`). const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { const renumbered = withOrdersFrom(mutate(withParallel), 0) as Row[]; const updates = renumbered.filter(r => r.orderNo !== r.order).map(r => ({ id: r.id, orderNo: r.order })); if (updates.length === 0) return; setReorderError(undefined); setReordering(true); Promise.all(updates.map(u => api.templateRouteSteps.patch(route.id, u.id, { orderNo: u.orderNo }))) .then(() => steps.reload()) .catch((e: Error) => setReorderError(texts.routeSteps.reorderFailed(reportError(e, 'RouteStepsScreen: reorder')))) .then(() => setReordering(false), () => setReordering(false)); }; const moveBlockUp = (blockIndex: number): void => { if (blockIndex <= 0) return; applyReorder(current => { const a = blocks[blockIndex - 1]; const b = blocks[blockIndex]; return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)]; }); }; const moveBlockDown = (blockIndex: number): void => { if (blockIndex >= blocks.length - 1) return; applyReorder(current => { const a = blocks[blockIndex]; const b = blocks[blockIndex + 1]; return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)]; }); }; // Первая строка списка объединяться не с чем — у неё переключатель недоступен. const toggleParallel = (rowIndex: number): void => { if (rowIndex <= 0) return; applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r))); }; const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1; return (
} /> {savedWarning && {savedWarning}} {steps.error && {steps.error}} {reorderError && {reorderError}} {deleteTarget.error && {deleteTarget.error}} {steps.items.length === 0 && (
{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
)} {steps.items.length > 0 && (
{withParallel.map((r, i) => { const blockIndex = blockIndexOf(blocks, i); const block = blocks[blockIndex]; const isBlockStart = block.start === i; const executor = describeStepExecutor(r, employees.items); return ( setHoveredBlock(blockIndex)} onMouseLeave={() => setHoveredBlock(undefined)} > {isBlockStart && ( )} {/* Стрелки — одна пара на весь блок (rowSpan): двигают шаг целиком, а не отдельного участника параллельной группы. */} {isBlockStart && ( )} ); })}
{texts.routeSteps.columnOrderNo} {texts.routeSteps.columnExecutor} {texts.routeSteps.columnStatus} {texts.routeSteps.columnReassign} {texts.routeSteps.columnCondition}
{block.end > block.start ? ( {r.order} ∥ ) : ( r.order )} {executor.primary} {executor.secondary && {executor.secondary}} {routeStepStatusLabels[r.status]} {r.allowReassignToOtherApprovers ? {texts.routeSteps.reassignAllowed} : {texts.common.no}} {describeStepCondition(r) ?? texts.common.empty} moveBlockUp(blockIndex)} /> = blocks.length - 1} onClick={() => moveBlockDown(blockIndex)} /> toggleParallel(i)} /> openEdit(r)} /> deleteTarget.ask(r)} />
)} {showForm && ( setShowForm(false)} /> )} {deleteTarget.target && ( )}
); };