первый вариант

This commit is contained in:
gorbenko.pavel
2026-08-19 16:22:50 +05:00
parent 2fe1696847
commit d9fa4b392b
17 changed files with 890 additions and 39 deletions
@@ -0,0 +1,353 @@
import * as React from 'react';
import styles from './ManageByUserScreen.module.scss';
import { ManageByUserReplaceModal } from './ManageByUserReplaceModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService';
import { ApiClientError } from '../../services/httpClient';
import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import { reportError } from '../../utils/errors';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import {
ActionsCell,
Alert,
Badge,
BadgeVariant,
Breadcrumbs,
Button,
Checkbox,
DataTable,
Field,
FormGrid,
IColumn,
IconButton,
PageHeader,
Panel,
PrimaryCell,
SearchableSelect,
Toast
} from '../ui';
export interface IManageByUserScreenProps {
api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
requestManage: RequestManageService;
}
// Активные статусы строки для этого экрана — сознательно уже, чем
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
// черновики сюда не входят (согласовано отдельно, см. план реализации).
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
// Promise.allSettled недоступен в целевом lib этого проекта (tsconfig.json —
// es5/es2015.*, без es2020.promise) — свой аналог вместо него: массовая замена
// не должна падать целиком из-за одной сломанной строки/шага (см.
// bulkReplaceLive/bulkReplaceTemplates ниже).
function settle<T>(p: Promise<T>): Promise<Settled<T>> {
return p.then((value): Settled<T> => ({ ok: true, value })).catch((error: unknown): Settled<T> => ({ ok: false, error }));
}
// Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen
// (дублирование небольшое и не стоит выносить ради одной строки).
function statusVariant(status: string): BadgeVariant {
if (status === REQUEST_STATUS.onExecution) return 'blue';
if (status === REQUEST_STATUS.queued) return 'neutral';
return 'gold';
}
function liveRowKey(r: UserApprovalRow): string {
return `${r.listId}:${r.id}`;
}
interface IReplaceScope {
live: UserApprovalRow[];
templates: UserTemplateStepRow[];
}
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage }) => {
const requestTypes = useRequestTypes(api);
const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary);
const [employeeEmail, setEmployeeEmail] = React.useState('');
const partitionsByType = React.useMemo(() => {
const map = new Map<number, RequestListPartition[]>();
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
return map;
}, [listSeries.items]);
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
const [liveLoading, setLiveLoading] = React.useState(false);
const [liveError, setLiveError] = React.useState<string | undefined>();
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
const reloadLiveRows = React.useCallback(() => {
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
setLiveLoading(true);
setLiveError(undefined);
requestManage
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
.then(setLiveRows)
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
.finally(() => setLiveLoading(false));
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
React.useEffect(() => {
setLiveRows([]);
setCheckedLiveKeys(new Set());
reloadLiveRows();
}, [reloadLiveRows]);
const templateSteps = useTemplateRouteStepsByExecutor(api, employeeEmail || undefined);
const routeIds = React.useMemo(() => templateSteps.items.map(s => s.routeId), [templateSteps.items]);
const routesLookup = useTemplateRoutesLookup(api, routeIds);
const [checkedTemplateIds, setCheckedTemplateIds] = React.useState<Set<number>>(new Set());
const templateRows: UserTemplateStepRow[] = React.useMemo(
() =>
templateSteps.items.map(step => {
const route = routesLookup.byId.get(step.routeId);
const requestType = route ? requestTypes.items.filter(rt => rt.id === route.requestTypeId)[0] : undefined;
const companyName = route?.companyId
? dictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
: texts.routeSteps.anyCompany;
const departmentName = route?.departmentId
? dictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
: texts.routeSteps.anyDepartment;
return {
...step,
requestTypeName: requestType?.name ?? (route ? `#${route.requestTypeId}` : texts.common.empty),
companyName,
departmentName
};
}),
[templateSteps.items, routesLookup.byId, requestTypes.items, dictionaries.companies, dictionaries.departments]
);
const toggleLive = (row: UserApprovalRow): void =>
setCheckedLiveKeys(prev => {
const next = new Set(prev);
const key = liveRowKey(row);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleTemplate = (row: UserTemplateStepRow): void =>
setCheckedTemplateIds(prev => {
const next = new Set(prev);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
return next;
});
const checkedLiveRows = liveRows.filter(r => checkedLiveKeys.has(liveRowKey(r)));
const checkedTemplateRows = templateRows.filter(r => checkedTemplateIds.has(r.id));
const [replaceScope, setReplaceScope] = React.useState<IReplaceScope | undefined>();
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<{ ok: number; fail: number }> {
const settled = await Promise.all(rows.map(r => settle(requestManage.reassignApprover(r, r.id, email, position))));
const ok = settled.filter(s => s.ok).length;
return { ok, fail: settled.length - ok };
}
async function bulkReplaceTemplates(
rows: UserTemplateStepRow[],
email: string
): Promise<{ ok: number; fail: number; conflicts: string[] }> {
const settled = await Promise.all(
rows.map(r => settle(api.templateRouteSteps.patch(r.routeId, r.id, { executorEmail: email })))
);
let ok = 0;
const conflicts: string[] = [];
settled.forEach((s, i) => {
if (s.ok) {
ok += 1;
return;
}
const reason = s.error as ApiClientError;
if (reason?.status === 409) {
const row = rows[i];
conflicts.push(texts.manageByUser.templateConflict(`${row.requestTypeName} — ${row.companyName}`, row.orderNo));
}
});
return { ok, fail: settled.length - ok, conflicts };
}
const handleReplaceConfirm = async (email: string): Promise<void> => {
if (!replaceScope) return;
const position = findEmployeeByEmail(employees.items, email)?.position || '';
const messages: string[] = [];
if (replaceScope.live.length > 0) {
const { ok, fail } = await bulkReplaceLive(replaceScope.live, email, position);
messages.push(texts.manageByUser.liveResult(ok, fail));
}
if (replaceScope.templates.length > 0) {
const { ok, fail, conflicts } = await bulkReplaceTemplates(replaceScope.templates, email);
messages.push(texts.manageByUser.templatesResult(ok, fail));
messages.push(...conflicts);
}
setResultToast({ message: messages.join(' '), kind: 'ok' });
setReplaceScope(undefined);
setCheckedLiveKeys(new Set());
setCheckedTemplateIds(new Set());
reloadLiveRows();
templateSteps.reload();
};
const liveColumns: IColumn<UserApprovalRow>[] = [
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedLiveKeys.has(liveRowKey(r))} onChange={() => toggleLive(r)} /> },
{ key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName },
{
key: 'request',
header: texts.manageByUser.columnRequest,
render: r => <PrimaryCell>{`${r.requestUniqueCode || `#${r.ticketId}`} ${r.requestTitle}`}</PrimaryCell>
},
{ key: 'position', header: texts.manageByUser.columnPosition, render: r => r.position || texts.common.empty },
{ key: 'status', header: texts.manageByUser.columnStatus, render: r => <Badge variant={statusVariant(r.status)}>{r.status}</Badge> },
{ key: 'phase', header: texts.manageByUser.columnPhase, render: r => r.phase },
{
key: 'actions',
header: '',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.manageByUser.replaceOne} onClick={() => setReplaceScope({ live: [r], templates: [] })} />
</ActionsCell>
)
}
];
const templateColumns: IColumn<UserTemplateStepRow>[] = [
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedTemplateIds.has(r.id)} onChange={() => toggleTemplate(r)} /> },
{
key: 'route',
header: texts.manageByUser.columnRoute,
render: r => <PrimaryCell>{`${r.requestTypeName} — ${r.companyName} / ${r.departmentName}`}</PrimaryCell>
},
{ key: 'stepStatus', header: texts.manageByUser.columnStepStatus, render: r => routeStepStatusLabels[r.status] },
{ key: 'condition', header: texts.manageByUser.columnCondition, render: r => describeStepCondition(r) ?? texts.common.empty },
{
key: 'actions',
header: '',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.manageByUser.replaceOne} onClick={() => setReplaceScope({ live: [], templates: [r] })} />
</ActionsCell>
)
}
];
const hasAnyRows = liveRows.length > 0 || templateRows.length > 0;
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageByUser.breadcrumb }]} />
<PageHeader
icon="people"
subtitle={texts.manageByUser.subtitle}
title={texts.manageByUser.title}
actions={
<Button
variant="accent"
disabled={!employeeEmail || !hasAnyRows}
onClick={() => setReplaceScope({ live: liveRows, templates: templateRows })}
>
{texts.manageByUser.replaceEverywhere}
</Button>
}
/>
<Panel>
<FormGrid>
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint} full>
<SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={employeeEmail}
loading={employees.isLoading}
placeholder={texts.manageByUser.employeePlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setEmployeeEmail}
/>
</Field>
</FormGrid>
</Panel>
{employeeEmail && (
<Panel>
<div className={styles.panelHeader}>
<div className={styles.panelTitle}>{`${texts.manageByUser.liveTitle} (${liveRows.length})`}</div>
<Button
variant="secondary"
size="sm"
disabled={checkedLiveRows.length === 0}
onClick={() => setReplaceScope({ live: checkedLiveRows, templates: [] })}
>
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
</Button>
</div>
{liveError && <Alert kind="err">{`${texts.manageByUser.liveLoadError}: ${liveError}`}</Alert>}
{liveLoading && <div>{texts.common.loading}</div>}
{!liveLoading && !liveError && (
<DataTable columns={liveColumns} rows={liveRows} keyOf={liveRowKey} emptyMessage={texts.manageByUser.liveEmpty} />
)}
</Panel>
)}
{employeeEmail && (
<Panel>
<div className={styles.panelHeader}>
<div className={styles.panelTitle}>{`${texts.manageByUser.templatesTitle} (${templateRows.length})`}</div>
<Button
variant="secondary"
size="sm"
disabled={checkedTemplateRows.length === 0}
onClick={() => setReplaceScope({ live: [], templates: checkedTemplateRows })}
>
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
</Button>
</div>
{templateSteps.error && <Alert kind="err">{`${texts.manageByUser.templatesLoadError}: ${templateSteps.error}`}</Alert>}
{templateSteps.isLoading && <div>{texts.common.loading}</div>}
{!templateSteps.isLoading && !templateSteps.error && (
<DataTable columns={templateColumns} rows={templateRows} keyOf={r => r.id} emptyMessage={texts.manageByUser.templatesEmpty} />
)}
</Panel>
)}
{replaceScope && (
<ManageByUserReplaceModal
title={texts.manageByUser.replaceModalTitle}
employees={employees.items}
employeesLoading={employees.isLoading}
onSave={handleReplaceConfirm}
onClose={() => setReplaceScope(undefined)}
/>
)}
{resultToast && (
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
)}
</div>
);
};