различные доработки

This commit is contained in:
gorbenko.pavel
2026-08-14 12:36:42 +05:00
parent 2eb63b5077
commit 10f70609ad
11 changed files with 361 additions and 73 deletions
@@ -1,14 +1,20 @@
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,
@@ -17,7 +23,6 @@ import {
Button,
CellNote,
ConfirmDialog,
DataTable,
IconButton,
PageHeader,
PrimaryCell
@@ -34,10 +39,17 @@ export interface IRouteStepsScreenProps {
onBack: () => void;
}
/** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */
type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean };
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ 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<string | undefined>();
const [reorderError, setReorderError] = React.useState<string | undefined>();
const [reordering, setReordering] = React.useState(false);
const [hoveredBlock, setHoveredBlock] = React.useState<number | undefined>(undefined);
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
@@ -48,6 +60,15 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
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);
@@ -63,6 +84,56 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
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 (
@@ -76,7 +147,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
/>
<PageHeader
icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName)}
subtitle={texts.routeSteps.subtitle(requestTypeName, companyName, departmentName)}
title={texts.routeSteps.title(route.id)}
actions={
<>
@@ -88,62 +159,124 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{reorderError && <Alert kind="err">{reorderError}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
{
key: 'executor',
header: texts.routeSteps.columnExecutor,
render: r => {
const executor = describeStepExecutor(r, employees.items);
return (
<div>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</div>
);
}
},
{
key: 'status',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
)
},
{
key: 'reassign',
header: texts.routeSteps.columnReassign,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'condition',
header: texts.routeSteps.columnCondition,
render: r => describeStepCondition(r) ?? texts.common.empty
},
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={steps.items}
keyOf={r => r.id}
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
/>
{steps.items.length === 0 && (
<div className={tableStyles.wrap}>
<div className={tableStyles.empty}>{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}</div>
</div>
)}
{steps.items.length > 0 && (
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
<div className={tableStyles.scroll}>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={styles.stepCol}>{texts.routeSteps.columnOrderNo}</th>
<th>{texts.routeSteps.columnExecutor}</th>
<th>{texts.routeSteps.columnStatus}</th>
<th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th>
<th>{texts.routeSteps.columnCondition}</th>
<th />
<th />
</tr>
</thead>
<tbody>
{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 (
<tr
key={r.id}
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
onMouseEnter={() => setHoveredBlock(blockIndex)}
onMouseLeave={() => setHoveredBlock(undefined)}
>
{isBlockStart && (
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
{block.end > block.start ? (
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
{r.order} ∥
</span>
) : (
r.order
)}
</td>
)}
<td>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</td>
<td>
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
</td>
<td style={{ textAlign: 'center' }}>
{r.allowReassignToOtherApprovers
? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge>
: <Badge variant="neutral">{texts.common.no}</Badge>}
</td>
<td>{describeStepCondition(r) ?? texts.common.empty}</td>
{/* Стрелки — одна пара на весь блок (rowSpan): двигают шаг
целиком, а не отдельного участника параллельной группы. */}
{isBlockStart && (
<td rowSpan={block.end - block.start + 1}>
<ActionsCell>
<IconButton
icon="chevron-down"
className={`${styles.iconBtnVisible} ${styles.flipY}`}
label={texts.routeSteps.moveUp}
disabled={reordering || blockIndex <= 0}
onClick={() => moveBlockUp(blockIndex)}
/>
<IconButton
icon="chevron-down"
className={styles.iconBtnVisible}
label={texts.routeSteps.moveDown}
disabled={reordering || blockIndex >= blocks.length - 1}
onClick={() => moveBlockDown(blockIndex)}
/>
</ActionsCell>
</td>
)}
<td>
<ActionsCell>
<IconButton
icon="people"
className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible}
label={r.parallelWithPrev ? texts.routeSteps.splitFromPrev : texts.routeSteps.mergeWithPrev}
disabled={reordering || i === 0}
onClick={() => toggleParallel(i)}
/>
<IconButton
icon="edit"
className={styles.iconBtnVisible}
label={texts.common.edit}
onClick={() => openEdit(r)}
/>
<IconButton
icon="trash"
className={styles.iconBtnVisible}
label={texts.common.remove}
danger
onClick={() => deleteTarget.ask(r)}
/>
</ActionsCell>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{showForm && (
<RouteStepFormModal