f3b0f0ad48
Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна. **Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута) - Черновик полей хранится в 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
213 lines
9.1 KiB
TypeScript
213 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
};
|