Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx
T
gorbenko.pavel f3b0f0ad48
Tests / frontend-test (push) Successful in 1m39s
Tests / backend-test (push) Successful in 9m27s
Персист модалок: черновики, восстановление после F5, предупреждение при выходе (#30)
Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна.

**Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута)
- Черновик полей хранится в localStorage (useFormDraft) и переживает закрытие окна, «Отмену» и F5.
- Окно, открытое в момент F5, открывается снова (usePersistedOpen); закрытие сбрасывает только признак, черновик остаётся.
- Черновик стирается после успешного сохранения или кнопкой «Очистить» в подвале.

**Формы редактирования**
- Не персистятся — открываются с данными сервера.
- При выходе с несохранёнными правками (крестик, «Отмена», клик мимо) — диалог «Закрыть без сохранения?» (isDirty в FormModal).

**Устойчивость к старым данным в localStorage**
- coerceStored приводит сохранённое значение к форме текущего дефолта по полям: новое поле — дефолт, удалённое отбрасывается, значение другого типа и null заменяются дефолтом. Покрыто тестами.

**Прочее**
- Modal и ConfirmDialog рендерятся порталом в body поверх шапки SharePoint — на живой странице верх высокого окна уезжал под шапку.
- Убраны Sass-предупреждения mixed-decls в Card.module.scss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/30
2026-10-19 04:46:16 +00:00

213 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal';
import { RouteCopyModal } from './RouteCopyModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { TemplateRoute } from '../../models';
import { texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { replaceRoute } from '../../routing';
import {
ActionsCell,
Alert,
Badge,
Breadcrumbs,
Button,
ConfirmDialog,
DataTable,
Field,
IconButton,
PageHeader,
PrimaryCell,
Select
} from '../ui';
export interface IRoutesScreenProps {
api: ApprovalRouteApi;
dictionary?: PortalDictionaryClient;
/** Фильтр из ссылки (aarfilter) — перекрывает сохранённый, если задан. */
initialFilterRequestTypeId?: string;
onOpenSteps: (route: TemplateRoute) => void;
}
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
api,
dictionary,
initialFilterRequestTypeId,
onOpenSteps
}) => {
const requestTypes = useRequestTypes(api);
const portalDictionaries = usePortalDictionaries(dictionary);
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 [editing, setEditing] = React.useState<TemplateRoute | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
const [showForm, setShowForm] = React.useState(false);
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
const closeForm = (): void => {
setShowForm(false);
createOpen.close();
};
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
const companyName = (id: string): string => portalDictionaries.companies.find(c => c.id === id)?.name ?? shortGuid(id);
const departmentName = (id: string): string => portalDictionaries.departments.find(d => d.id === id)?.name ?? shortGuid(id);
const openCreate = (): void => {
setEditing(undefined);
setShowForm(false);
createOpen.open();
};
const openEdit = (item: TemplateRoute): void => {
setEditing(item);
createOpen.close();
setShowForm(true);
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routes.breadcrumb }]} />
<PageHeader
icon="route"
subtitle={texts.routes.subtitle}
title={texts.routes.title}
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routes.add}</Button>}
/>
<div className={styles.filter}>
<Field label={texts.routes.filterLabel}>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.routes.filterAll}
value={filterRequestTypeId}
onChange={e => setFilterRequestTypeId(e.target.value)}
/>
</Field>
</div>
{portalDictionaries.error && <Alert kind="warn">{texts.routes.dictionaryFailed(portalDictionaries.error)}</Alert>}
{routes.error && <Alert kind="err">{routes.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'id', header: texts.routes.columnId, width: '90px', render: r => `#${r.id}` },
{
key: 'requestType',
header: texts.routes.columnRequestType,
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
},
{ key: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) },
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
{
key: 'byProject',
header: texts.routes.columnByProject,
align: 'center',
render: r => (r.byProject ? <Badge variant="ok">{texts.common.yes}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'control',
header: texts.routes.columnControl,
align: 'center',
render: r => (r.requiresControl ? <Badge variant="gold">{texts.routes.controlRequired}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'actions',
header: '',
width: '160px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
<IconButton icon="copy" label={texts.routes.copy} onClick={() => setCopying(r)} />
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={routes.items}
keyOf={r => r.id}
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
/>
{(showForm || createOpen.isOpen) && (
<RouteFormModal
api={api}
editing={editing}
initialRequestTypeId={filterRequestTypeId}
requestTypes={requestTypes.items}
companies={portalDictionaries.companies}
departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading}
onSaved={routes.reload}
onClose={closeForm}
/>
)}
{copying && (
<RouteCopyModal
api={api}
source={copying}
requestTypeName={requestTypeName(copying.requestTypeId)}
companies={portalDictionaries.companies}
departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading}
onCopied={routes.reload}
onClose={() => setCopying(undefined)}
/>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.routes.deleteTitle}
text={texts.routes.deleteText(deleteTarget.target.id)}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};