309 lines
14 KiB
TypeScript
309 lines
14 KiB
TypeScript
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<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>();
|
|
const [showForm, setShowForm] = React.useState(false);
|
|
|
|
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
|
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 (
|
|
<div>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: texts.common.home },
|
|
{ label: texts.routes.breadcrumb, onClick: onBack },
|
|
{ label: texts.routeSteps.breadcrumb(route.id) }
|
|
]}
|
|
/>
|
|
<PageHeader
|
|
icon="flag-check"
|
|
subtitle={texts.routeSteps.subtitle(requestTypeName, companyName, departmentName)}
|
|
title={texts.routeSteps.title(route.id)}
|
|
actions={
|
|
<>
|
|
<Button variant="ghost" icon="arrow-left" onClick={onBack}>{texts.routeSteps.back}</Button>
|
|
<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routeSteps.add}</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{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>}
|
|
|
|
{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
|
|
api={api}
|
|
route={route}
|
|
requestType={requestType}
|
|
sharePoint={sharePoint}
|
|
editing={editing}
|
|
nextOrderNo={nextOrderNo}
|
|
employees={employees.items}
|
|
employeesLoading={employees.isLoading}
|
|
onSaved={handleSaved}
|
|
onClose={() => setShowForm(false)}
|
|
/>
|
|
)}
|
|
|
|
{deleteTarget.target && (
|
|
<ConfirmDialog
|
|
title={texts.routeSteps.deleteTitle}
|
|
text={texts.routeSteps.deleteText(deleteTarget.target.orderNo)}
|
|
confirmLabel={texts.common.remove}
|
|
busy={deleteTarget.isBusy}
|
|
onCancel={deleteTarget.cancel}
|
|
onConfirm={deleteTarget.confirm}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|