различные доработки
This commit is contained in:
@@ -37,7 +37,9 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
api.requestTypes.get(r.requestTypeId)
|
api.requestTypes.get(r.requestTypeId)
|
||||||
.then(setSelectedRequestType)
|
.then(setSelectedRequestType)
|
||||||
.catch(() => setSelectedRequestType(undefined));
|
.catch(() => setSelectedRequestType(undefined));
|
||||||
navigate({ tab: 'routes', routeId: r.id });
|
// routesFilter несём с собой: возврат «к маршрутам» должен показать тот же
|
||||||
|
// отфильтрованный список, из которого ушли (см. onBack ниже).
|
||||||
|
navigate({ tab: 'routes', routeId: r.id, routesFilter: route.routesFilter });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL
|
// Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL
|
||||||
@@ -79,7 +81,12 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
{route.tab === 'routes' && !route.routeId && (
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
<RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />
|
<RoutesScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
initialFilterRequestTypeId={route.routesFilter}
|
||||||
|
onOpenSteps={openSteps}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||||
<RouteStepsScreen
|
<RouteStepsScreen
|
||||||
@@ -88,7 +95,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
requestType={selectedRequestType}
|
requestType={selectedRequestType}
|
||||||
dictionary={dictionary}
|
dictionary={dictionary}
|
||||||
sharePoint={sharePoint}
|
sharePoint={sharePoint}
|
||||||
onBack={() => navigate({ tab: 'routes' })}
|
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{route.tab === 'resolve' && (
|
{route.tab === 'resolve' && (
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Таблица шагов маршрута собрана вручную (а не через DataTable) из-за rowSpan:
|
||||||
|
// колонка «№» и стрелки перестановки объединяются на весь блок параллельных
|
||||||
|
// шагов. Стили повторяют ManageRequestsScreen.module.scss — там та же механика
|
||||||
|
// для строк заявки (см. ManageRequestsPhaseTable).
|
||||||
|
|
||||||
|
.stepCol {
|
||||||
|
width: 56px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥,
|
||||||
|
// иначе два одинаковых номера подряд выглядят как ошибка данных.
|
||||||
|
.parallelMark {
|
||||||
|
color: $gold-600;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flipY {
|
||||||
|
svg { transform: scaleY(-1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Штатную подсветку строки отключаем: ячейки с rowSpan принадлежат <tr> только
|
||||||
|
// первой строки блока, поэтому CSS :hover оставлял «вырез» в подсветке на
|
||||||
|
// границе объединённой ячейки. Подсвечиваем блок целиком через React-состояние.
|
||||||
|
.blockAwareWrap :global(tbody tr:hover) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowHover td {
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconButton по умолчанию прозрачная (рамка только по hover). Здесь кнопок
|
||||||
|
// много и часть неочевидна по смыслу (параллельность, перестановка) — рамка
|
||||||
|
// нужна всегда, иначе выглядит как текст. !important: это разные CSS-модули,
|
||||||
|
// порядок их правил в бандле не гарантирован.
|
||||||
|
.iconBtnVisible {
|
||||||
|
border-color: $gray-300 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Объединить с предыдущим» — переключатель состояния, а не разовое действие,
|
||||||
|
// поэтому включённое состояние залито.
|
||||||
|
.iconBtnActive {
|
||||||
|
border-color: $gold-500 !important;
|
||||||
|
background: $gold-500 !important;
|
||||||
|
color: $navy-950 !important;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $gold-600 !important;
|
||||||
|
background: $gold-600 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import tableStyles from '../ui/DataTable.module.scss';
|
||||||
|
import styles from './RouteStepsScreen.module.scss';
|
||||||
import { RouteStepFormModal } from './RouteStepFormModal';
|
import { RouteStepFormModal } from './RouteStepFormModal';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||||
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
|
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
|
||||||
import { routeStepStatusLabels, texts } from '../../texts';
|
import { routeStepStatusLabels, texts } from '../../texts';
|
||||||
|
import { shortGuid } from '../../utils/guid';
|
||||||
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
|
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 { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -17,7 +23,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
CellNote,
|
CellNote,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DataTable,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PrimaryCell
|
PrimaryCell
|
||||||
@@ -34,10 +39,17 @@ export interface IRouteStepsScreenProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */
|
||||||
|
type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean };
|
||||||
|
|
||||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
|
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
|
||||||
const steps = useRouteSteps(api, route.id);
|
const steps = useRouteSteps(api, route.id);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||||
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
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 requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
@@ -48,6 +60,15 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
steps.reload
|
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 => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@@ -63,6 +84,56 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
steps.reload();
|
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;
|
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +147,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
/>
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon="flag-check"
|
icon="flag-check"
|
||||||
subtitle={texts.routeSteps.subtitle(requestTypeName)}
|
subtitle={texts.routeSteps.subtitle(requestTypeName, companyName, departmentName)}
|
||||||
title={texts.routeSteps.title(route.id)}
|
title={texts.routeSteps.title(route.id)}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@@ -88,62 +159,124 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
|
|
||||||
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
|
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
|
||||||
{steps.error && <Alert kind="err">{steps.error}</Alert>}
|
{steps.error && <Alert kind="err">{steps.error}</Alert>}
|
||||||
|
{reorderError && <Alert kind="err">{reorderError}</Alert>}
|
||||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||||
|
|
||||||
<DataTable
|
{steps.items.length === 0 && (
|
||||||
columns={[
|
<div className={tableStyles.wrap}>
|
||||||
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
|
<div className={tableStyles.empty}>{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}</div>
|
||||||
{
|
</div>
|
||||||
key: 'executor',
|
)}
|
||||||
header: texts.routeSteps.columnExecutor,
|
|
||||||
render: r => {
|
{steps.items.length > 0 && (
|
||||||
const executor = describeStepExecutor(r, employees.items);
|
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
|
||||||
return (
|
<div className={tableStyles.scroll}>
|
||||||
<div>
|
<table className={tableStyles.table}>
|
||||||
<PrimaryCell>{executor.primary}</PrimaryCell>
|
<thead>
|
||||||
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
<tr>
|
||||||
</div>
|
<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>
|
||||||
key: 'status',
|
<th />
|
||||||
header: texts.routeSteps.columnStatus,
|
<th />
|
||||||
render: r => (
|
</tr>
|
||||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
|
</thead>
|
||||||
{routeStepStatusLabels[r.status]}
|
<tbody>
|
||||||
</Badge>
|
{withParallel.map((r, i) => {
|
||||||
)
|
const blockIndex = blockIndexOf(blocks, i);
|
||||||
},
|
const block = blocks[blockIndex];
|
||||||
{
|
const isBlockStart = block.start === i;
|
||||||
key: 'reassign',
|
const executor = describeStepExecutor(r, employees.items);
|
||||||
header: texts.routeSteps.columnReassign,
|
|
||||||
align: 'center',
|
return (
|
||||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
<tr
|
||||||
},
|
key={r.id}
|
||||||
{
|
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
|
||||||
key: 'condition',
|
onMouseEnter={() => setHoveredBlock(blockIndex)}
|
||||||
header: texts.routeSteps.columnCondition,
|
onMouseLeave={() => setHoveredBlock(undefined)}
|
||||||
render: r => describeStepCondition(r) ?? texts.common.empty
|
>
|
||||||
},
|
{isBlockStart && (
|
||||||
{
|
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
||||||
key: 'actions',
|
{block.end > block.start ? (
|
||||||
header: '',
|
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
|
||||||
width: '100px',
|
{r.order} ∥
|
||||||
align: 'right',
|
</span>
|
||||||
sticky: true,
|
) : (
|
||||||
render: r => (
|
r.order
|
||||||
<ActionsCell>
|
)}
|
||||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
</td>
|
||||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
)}
|
||||||
</ActionsCell>
|
<td>
|
||||||
)
|
<PrimaryCell>{executor.primary}</PrimaryCell>
|
||||||
}
|
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
||||||
]}
|
</td>
|
||||||
rows={steps.items}
|
<td>
|
||||||
keyOf={r => r.id}
|
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
|
||||||
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
|
{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 && (
|
{showForm && (
|
||||||
<RouteStepFormModal
|
<RouteStepFormModal
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
|||||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
|
import { replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -28,14 +30,41 @@ import {
|
|||||||
export interface IRoutesScreenProps {
|
export interface IRoutesScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
dictionary?: PortalDictionaryClient;
|
dictionary?: PortalDictionaryClient;
|
||||||
|
/** Фильтр из ссылки (aarfilter) — перекрывает сохранённый, если задан. */
|
||||||
|
initialFilterRequestTypeId?: string;
|
||||||
onOpenSteps: (route: TemplateRoute) => void;
|
onOpenSteps: (route: TemplateRoute) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||||
|
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||||
|
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||||
|
|
||||||
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||||
|
api,
|
||||||
|
dictionary,
|
||||||
|
initialFilterRequestTypeId,
|
||||||
|
onOpenSteps
|
||||||
|
}) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
|
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ROUTES_FILTER_STORAGE_KEY, '');
|
||||||
|
|
||||||
|
// Открыли по ссылке с фильтром — она сильнее сохранённого значения. Только на
|
||||||
|
// монтировании: дальше источник истины — сам экран (он же пишет фильтр в URL
|
||||||
|
// ниже), иначе собственный replaceRoute тут же вернулся бы сюда как «внешний».
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Держим URL синхронным с фильтром — ссылку на отфильтрованный список можно
|
||||||
|
// скопировать. replaceRoute, а не navigate: смена фильтра не должна плодить
|
||||||
|
// записи в истории браузера.
|
||||||
|
React.useEffect(() => {
|
||||||
|
replaceRoute({ tab: 'routes', routesFilter: filterRequestTypeId });
|
||||||
|
}, [filterRequestTypeId]);
|
||||||
|
|
||||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
@@ -87,6 +116,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
|||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
|
{ key: 'id', header: texts.routes.columnId, width: '90px', render: r => `#${r.id}` },
|
||||||
{
|
{
|
||||||
key: 'requestType',
|
key: 'requestType',
|
||||||
header: texts.routes.columnRequestType,
|
header: texts.routes.columnRequestType,
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ describe('parseRoute', () => {
|
|||||||
expect(parseRoute('?aatab=routes').routeId).toBeUndefined();
|
expect(parseRoute('?aatab=routes').routeId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aarfilter учитывается только при tab=routes', () => {
|
||||||
|
expect(parseRoute('?aatab=routes&aarfilter=11').routesFilter).toBe('11');
|
||||||
|
expect(parseRoute('?aatab=manageRequests&aarfilter=11').routesFilter).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтр живёт и при открытых шагах: возврат «к маршрутам» должен вернуть
|
||||||
|
// к тому же отфильтрованному списку (см. AdminApp.onBack).
|
||||||
|
it('aarfilter читается вместе с aaroute', () => {
|
||||||
|
const route = parseRoute('?aatab=routes&aaroute=7&aarfilter=11');
|
||||||
|
expect(route.routeId).toBe(7);
|
||||||
|
expect(route.routesFilter).toBe('11');
|
||||||
|
});
|
||||||
|
|
||||||
it('resolve.fieldValues парсит валидный JSON-массив', () => {
|
it('resolve.fieldValues парсит валидный JSON-массив', () => {
|
||||||
const fields = encodeURIComponent(JSON.stringify([{ fieldName: 'Amount', value: '100' }]));
|
const fields = encodeURIComponent(JSON.stringify([{ fieldName: 'Amount', value: '100' }]));
|
||||||
const route = parseRoute(`?aatab=resolve&aartype=1&aafields=${fields}`);
|
const route = parseRoute(`?aatab=resolve&aartype=1&aafields=${fields}`);
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export interface IAdminRoute {
|
|||||||
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
|
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
|
||||||
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
|
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
|
||||||
routeId?: number;
|
routeId?: number;
|
||||||
|
/**
|
||||||
|
* Фильтр списка маршрутов по типу заявки (id как строка, '' — все типы).
|
||||||
|
* Живёт и при открытых шагах (routeId): возврат «к маршрутам» должен вернуть
|
||||||
|
* к тому же отфильтрованному списку, а не сбросить фильтр.
|
||||||
|
*/
|
||||||
|
routesFilter?: string;
|
||||||
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
||||||
resolve?: IResolveRouteParams;
|
resolve?: IResolveRouteParams;
|
||||||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||||
@@ -56,7 +62,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
|||||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
||||||
|
|
||||||
const PARAM_KEYS = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||||
'aamtype', 'aamreq'
|
'aamtype', 'aamreq'
|
||||||
];
|
];
|
||||||
@@ -114,6 +120,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
|||||||
return {
|
return {
|
||||||
tab,
|
tab,
|
||||||
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||||
|
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||||
resolve,
|
resolve,
|
||||||
manage
|
manage
|
||||||
};
|
};
|
||||||
@@ -127,8 +134,9 @@ function buildQuery(route: IAdminRoute): string {
|
|||||||
|
|
||||||
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
|
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
|
||||||
|
|
||||||
if (route.tab === 'routes' && route.routeId) {
|
if (route.tab === 'routes') {
|
||||||
params.set('aaroute', String(route.routeId));
|
if (route.routeId) params.set('aaroute', String(route.routeId));
|
||||||
|
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export const texts = {
|
|||||||
filterAll: 'Все типы заявок',
|
filterAll: 'Все типы заявок',
|
||||||
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
|
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
|
||||||
emptyList: 'Маршрутов пока нет.',
|
emptyList: 'Маршрутов пока нет.',
|
||||||
|
columnId: '№ маршрута',
|
||||||
columnRequestType: 'Тип заявки',
|
columnRequestType: 'Тип заявки',
|
||||||
columnCompany: 'Компания',
|
columnCompany: 'Компания',
|
||||||
columnDepartment: 'Отдел',
|
columnDepartment: 'Отдел',
|
||||||
@@ -194,10 +195,20 @@ export const texts = {
|
|||||||
routeSteps: {
|
routeSteps: {
|
||||||
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
|
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
|
||||||
title: (routeId: number): string => `Маршрут #${routeId}`,
|
title: (routeId: number): string => `Маршрут #${routeId}`,
|
||||||
subtitle: (requestTypeName: string): string => requestTypeName,
|
/** Подзаголовок экрана шагов: по нему видно, ЧЕЙ это маршрут (тип заявки · компания · отдел). */
|
||||||
|
subtitle: (requestTypeName: string, company: string, department: string): string =>
|
||||||
|
`${requestTypeName} · ${company} · ${department}`,
|
||||||
|
/** Маршрут без компании/отдела — запасной, применяется когда своего маршрута нет. */
|
||||||
|
anyCompany: 'любая компания',
|
||||||
|
anyDepartment: 'любой отдел',
|
||||||
back: 'К маршрутам',
|
back: 'К маршрутам',
|
||||||
add: 'Добавить шаг',
|
add: 'Добавить шаг',
|
||||||
emptyList: 'Шагов пока нет — добавьте первый.',
|
emptyList: 'Шагов пока нет — добавьте первый.',
|
||||||
|
moveUp: 'Шаг выше',
|
||||||
|
moveDown: 'Шаг ниже',
|
||||||
|
mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',
|
||||||
|
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
|
||||||
|
reorderFailed: (message: string): string => `Не удалось изменить порядок шагов: ${message}`,
|
||||||
columnOrderNo: '№',
|
columnOrderNo: '№',
|
||||||
columnExecutor: 'Исполнитель',
|
columnExecutor: 'Исполнитель',
|
||||||
columnStatus: 'Статус',
|
columnStatus: 'Статус',
|
||||||
|
|||||||
@@ -26,11 +26,21 @@ describe('findEmployeeByEmail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('toEmployeeOption', () => {
|
describe('toEmployeeOption', () => {
|
||||||
it('строит опцию для SearchableSelect из сотрудника', () => {
|
it('строит опцию для SearchableSelect из сотрудника: должность и email в подписи', () => {
|
||||||
expect(toEmployeeOption(employees[0])).toEqual({
|
expect(toEmployeeOption(employees[0])).toEqual({
|
||||||
value: 'gorbenko.pavel@tnsu.kz',
|
value: 'gorbenko.pavel@tnsu.kz',
|
||||||
label: 'Горбенко Павел',
|
label: 'Горбенко Павел',
|
||||||
sublabel: 'gorbenko.pavel@tnsu.kz'
|
sublabel: 'Разработчик middle · gorbenko.pavel@tnsu.kz'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Должность в справочнике заполнена не у всех — тогда подпись не должна
|
||||||
|
// начинаться с висящего разделителя.
|
||||||
|
it('без должности в подписи остаётся только email', () => {
|
||||||
|
expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '' })).toEqual({
|
||||||
|
value: 'nobody@tnsu.kz',
|
||||||
|
label: 'Без Должности',
|
||||||
|
sublabel: 'nobody@tnsu.kz'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { Employee } from '../models';
|
import { Employee } from '../models';
|
||||||
import { ISearchableOption } from '../components/ui';
|
import { ISearchableOption } from '../components/ui';
|
||||||
|
|
||||||
/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */
|
/**
|
||||||
|
* Опция для SearchableSelect: ФИО — основная строка, должность и email — подпись под ней.
|
||||||
|
*
|
||||||
|
* Должность здесь не только для узнавания однофамильцев: SearchableSelect ищет и по
|
||||||
|
* sublabel (см. filtered там), поэтому согласующего можно найти, набрав «начальник омтс»,
|
||||||
|
* не помня фамилии. Должность может быть пустой (в справочнике не у всех заполнена) —
|
||||||
|
* тогда в подписи остаётся только email.
|
||||||
|
*/
|
||||||
export function toEmployeeOption(employee: Employee): ISearchableOption {
|
export function toEmployeeOption(employee: Employee): ISearchableOption {
|
||||||
return { value: employee.email, label: employee.fio, sublabel: employee.email };
|
const sublabel = [employee.position, employee.email].filter(Boolean).join(' · ');
|
||||||
|
return { value: employee.email, label: employee.fio, sublabel };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
|
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
|
||||||
|
|||||||
@@ -46,9 +46,18 @@ describe('detectExecutorKind', () => {
|
|||||||
describe('describeStepExecutor', () => {
|
describe('describeStepExecutor', () => {
|
||||||
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
|
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
|
||||||
|
|
||||||
it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => {
|
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
|
||||||
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
|
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
|
||||||
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' });
|
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'Разработчик · gorbenko.pavel@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Должность в справочнике заполнена не у всех — тогда подпись не должна
|
||||||
|
// начинаться с висящего разделителя.
|
||||||
|
it('для сотрудника без должности в подписи остаётся только email', () => {
|
||||||
|
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
|
||||||
|
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '' }
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
|
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function detectExecutorKind(step: {
|
|||||||
|
|
||||||
export interface StepExecutorDisplay {
|
export interface StepExecutorDisplay {
|
||||||
primary: string;
|
primary: string;
|
||||||
/** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
|
/** Должность и email под ФИО — когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
|
||||||
secondary?: string;
|
secondary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,10 @@ export interface StepExecutorDisplay {
|
|||||||
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
||||||
if (step.executorEmail) {
|
if (step.executorEmail) {
|
||||||
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
|
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
|
||||||
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
|
if (!employee) return { primary: step.executorEmail };
|
||||||
|
// Должность рядом с email: по одному ФИО не видно, тот ли это человек, что
|
||||||
|
// задумывался при настройке маршрута (в справочнике есть однофамильцы).
|
||||||
|
return { primary: employee.fio, secondary: [employee.position, employee.email].filter(Boolean).join(' · ') };
|
||||||
}
|
}
|
||||||
if (step.position) return { primary: step.position };
|
if (step.position) return { primary: step.position };
|
||||||
if (step.projectRole && step.projectRole !== ProjectRole.None) {
|
if (step.projectRole && step.projectRole !== ProjectRole.None) {
|
||||||
|
|||||||
Reference in New Issue
Block a user