import * as React from 'react'; import styles from './ManageByUserScreen.module.scss'; import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { RequestManageService } from '../../services/requestManageService'; import { ApiClientError } from '../../services/httpClient'; import { REQUEST_STATUS, 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 { mapWithLimit } from '../../utils/parallel'; import { IManageByUserRouteParams, replaceRoute } from '../../routing'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useRequestTypes } from '../../hooks/useRequestTypes'; 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, ProgressOverlay, SearchableSelect, Toast } from '../ui'; export interface IManageByUserScreenProps { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; requestManage: RequestManageService; /** Заполнение из URL (глубокая ссылка на конкретного сотрудника) — см. routing.ts. */ route?: IManageByUserRouteParams; } // Выбор (кого меняем и на кого) переживает перезагрузку страницы — как и выбор // заявки в ManageRequestsScreen (тот же приём, отдельный ключ). const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserSelection'; interface IPersistedSelection { employeeEmail: string; targetEmail: string; } const EMPTY_SELECTION: IPersistedSelection = { employeeEmail: '', targetEmail: '' }; // Активные статусы строки для этого экрана — сознательно уже, чем // isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и // черновики сюда не входят (согласовано отдельно, см. план реализации). const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution]; // Итог пакетной операции по одной панели. interface IBulkOutcome { ok: number; fail: number; /** Дубли, снятые удалением старой строки/шага (пользователь выбрал «удалить»). */ deleted: number; /** Дубли, оставленные как есть (пользователь выбрал «пропустить»). */ skipped: number; } // Та же цветовая группировка статуса заявки, что и в 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 = ({ api, dictionary, requestManage, route }) => { const requestTypes = useRequestTypes(api); const employees = usePortalEmployees(dictionary); const dictionaries = usePortalDictionaries(dictionary); const [selection, setSelection] = useLocalStorageState(SELECTION_STORAGE_KEY, EMPTY_SELECTION); const { employeeEmail, targetEmail } = selection; const selectEmployee = (email: string): void => setSelection(prev => ({ ...prev, employeeEmail: email })); const selectTarget = (email: string): void => setSelection(prev => ({ ...prev, targetEmail: email })); const swapEmployees = (): void => setSelection(prev => ({ employeeEmail: prev.targetEmail, targetEmail: prev.employeeEmail })); // Открыли по глубокой ссылке (aabuser/aabtarget в URL) — перекрывает то, что // было в localStorage. Только на монтировании: route меняется при каждом // собственном replaceRoute ниже (тот же приём, что в ManageRequestsScreen). React.useEffect(() => { if (route?.userEmail) setSelection({ employeeEmail: route.userEmail, targetEmail: route.targetEmail }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Держим URL синхронным с выбором — открытый экран можно скопировать и // прислать коллеге. replaceRoute, а не navigate: смена сотрудника — правка // состояния текущего экрана, а не отдельный шаг в истории браузера. React.useEffect(() => { replaceRoute({ tab: 'manageByUser', manageByUser: employeeEmail ? { userEmail: employeeEmail, targetEmail } : undefined }); }, [employeeEmail, targetEmail]); const [liveRows, setLiveRows] = React.useState([]); const [liveLoading, setLiveLoading] = React.useState(false); const [liveError, setLiveError] = React.useState(); const [checkedLiveKeys, setCheckedLiveKeys] = React.useState>(new Set()); const reloadLiveRows = React.useCallback(() => { if (!employeeEmail || requestTypes.isLoading) return; setLiveLoading(true); setLiveError(undefined); api.requestManage .byApprover(employeeEmail, ACTIVE_STATUSES) .then(setLiveRows) .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover'))) .finally(() => setLiveLoading(false)); }, [api, requestTypes.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>(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 allLiveChecked = liveRows.length > 0 && checkedLiveRows.length === liveRows.length; const toggleAllLive = (): void => setCheckedLiveKeys(allLiveChecked ? new Set() : new Set(liveRows.map(liveRowKey))); const allTemplatesChecked = templateRows.length > 0 && checkedTemplateRows.length === templateRows.length; const toggleAllTemplates = (): void => setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id))); const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); // Пока задан — интерфейс заблокирован подложкой с полосой прогресса // (ProgressOverlay), и запустить вторую замену поверх первой нельзя. const [progress, setProgress] = React.useState<{ done: number; total: number } | undefined>(); // Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл // замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно // из-за этого диалога: параллельные ветки открыли бы N окон разом. const [duplicatePrompt, setDuplicatePrompt] = React.useState<{ message: string; resolve: (choice: DuplicateChoice) => void } | undefined>(); // «…для всех» на время ОДНОЙ пакетной операции — ref, а не state: значение // читается внутри уже запущенного цикла, и перерисовка ему не нужна. const duplicatePolicy = React.useRef(undefined); // Шаг вперёд по полосе прогресса. Вызывается ПЕРЕД обработкой очередной // строки: показывает «делаю N-ю из M», а не «N уже готово». const advanceProgress = (): void => setProgress(prev => (prev ? { done: Math.min(prev.done + 1, prev.total), total: prev.total } : prev)); const askDuplicate = (message: string): Promise => { if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current); return new Promise(resolve => setDuplicatePrompt({ message, resolve })); }; const answerDuplicate = (choice: DuplicateChoice, forAll: boolean): void => { if (forAll) duplicatePolicy.current = choice; const pending = duplicatePrompt; setDuplicatePrompt(undefined); pending?.resolve(choice); }; /** * Замена в живых заявках — тремя фазами, чтобы совместить параллельность с * интерактивным разрешением дублей: * * 1. ПАРАЛЛЕЛЬНО проверяем каждую строку на дубль (чтение, ничего не пишет) * и заодно параллельно заменяем все «чистые» строки; * 2. ПОСЛЕДОВАТЕЛЬНО спрашиваем пользователя по каждому найденному дублю — * диалог по определению один за раз (параллельно открылось бы N окон); * 3. ПАРАЛЛЕЛЬНО удаляем те строки, по которым выбрали «удалить». * * Дубль (тот же человек уже стоит в этой же фазе этой же заявки) SharePoint * сам не запрещает — если не проверить, в маршруте появились бы две строки на * одного человека, и заявка ушла бы ему на согласование дважды. */ async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise { const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; // Фаза 1: проверка дублей. Прогресс здесь не двигаем — это ещё не работа по // строке, а подготовка (строка может оказаться и заменена, и удалена). const checks = await mapWithLimit(rows, row => requestManage.hasApproverInSamePhase(row, email)); const clean: UserApprovalRow[] = []; const conflicting: UserApprovalRow[] = []; checks.forEach((result, i) => { if (!result.ok) { reportError(result.error, 'ManageByUserScreen: hasApproverInSamePhase'); outcome.fail += 1; advanceProgress(); return; } (result.value ? conflicting : clean).push(rows[i]); }); // Фаза 2: «чистые» строки — параллельно, друг от друга не зависят. const replaced = await mapWithLimit( clean, row => requestManage.reassignApprover(row, row.id, email, position, { ticketId: row.ticketId, dataListId: row.listId }), { onSettled: advanceProgress } ); replaced.forEach(result => { if (result.ok) outcome.ok += 1; else { reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/reassign'); outcome.fail += 1; } }); // Фаза 3: дубли — спрашиваем по одному, накапливая тех, кого удалять. const toDelete: UserApprovalRow[] = []; for (const row of conflicting) { const label = `${row.requestUniqueCode || `#${row.ticketId}`} ${row.requestTitle}`; const choice = await askDuplicate(texts.manageByUser.duplicateLiveMessage(label, row.position, email)); if (choice === 'skip') { outcome.skipped += 1; advanceProgress(); } else { toDelete.push(row); } } const removed = await mapWithLimit(toDelete, row => requestManage.removeRow(row, row.id), { onSettled: advanceProgress }); removed.forEach(result => { if (result.ok) outcome.deleted += 1; else { reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/remove'); outcome.fail += 1; } }); return outcome; } /** * Замена в шаблонах маршрутов — та же трёхфазная схема, что и для живых * заявок, но дубль здесь ловит СЕРВЕР (HasDuplicateAsync в * TemplateRouteStepService → 409): своей предварительной проверки не делаем, * его правило шире email'а (совпадать должны ещё статус и условие появления), * и дублировать это на клиенте значило бы разъехаться с ним при первой же * правке. Поэтому «проверка» и есть сам PATCH — он идёт параллельно, а * разбираем 409-ые уже после. */ async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise { const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; const patched = await mapWithLimit( rows, row => api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email }), { onSettled: advanceProgress } ); const conflicting: UserTemplateStepRow[] = []; patched.forEach((result, i) => { if (result.ok) { outcome.ok += 1; return; } if ((result.error as ApiClientError)?.status === 409) { conflicting.push(rows[i]); return; } reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates'); outcome.fail += 1; }); const toDelete: UserTemplateStepRow[] = []; for (const row of conflicting) { const label = `${row.requestTypeName} — ${row.companyName}`; const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email)); if (choice === 'skip') outcome.skipped += 1; else toDelete.push(row); } const removed = await mapWithLimit(toDelete, row => api.templateRouteSteps.remove(row.routeId, row.id)); removed.forEach(result => { if (result.ok) outcome.deleted += 1; else { reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates/remove'); outcome.fail += 1; } }); return outcome; } const runReplace = async (scope: IReplaceScope): Promise => { const total = scope.live.length + scope.templates.length; if (!targetEmail || total === 0 || progress) return; const position = findEmployeeByEmail(employees.items, targetEmail)?.position || ''; const messages: string[] = []; // Политика «…для всех» живёт ровно одну операцию — иначе следующая замена // молча унаследовала бы решение, принятое для совсем другого списка строк. duplicatePolicy.current = undefined; setProgress({ done: 0, total }); try { if (scope.live.length > 0) { const r = await bulkReplaceLive(scope.live, targetEmail, position); messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped)); } if (scope.templates.length > 0) { const r = await bulkReplaceTemplates(scope.templates, targetEmail); messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped)); } } finally { // Снимать блокировку обязательно в finally: если что-то бросит мимо // внутренних try/catch, подложка иначе осталась бы висеть навсегда и // экран стал бы неработоспособен до перезагрузки страницы. setProgress(undefined); duplicatePolicy.current = undefined; } setResultToast({ message: messages.join(' '), kind: 'ok' }); setCheckedLiveKeys(new Set()); setCheckedTemplateIds(new Set()); reloadLiveRows(); templateSteps.reload(); }; // Сам себя заменить нельзя — выбор бессмысленный (и в живых заявках сразу // упёрся бы в проверку дубля), поэтому исходного сотрудника в списке // получателей не показываем вовсе. const targetOptions = employees.items .filter(e => e.email.toLowerCase() !== employeeEmail.toLowerCase()) .map(toEmployeeOption); // Пока получатель не выбран, менять нечего — все кнопки замены выключены. const canReplace = !!employeeEmail && !!targetEmail; const liveColumns: IColumn[] = [ { key: 'check', header: '', width: '32px', render: r => toggleLive(r)} /> }, { key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName }, { key: 'request', header: texts.manageByUser.columnRequest, render: r => {`${r.requestUniqueCode || `#${r.ticketId}`} ${r.requestTitle}`} }, { key: 'position', header: texts.manageByUser.columnPosition, render: r => r.position || texts.common.empty }, { key: 'status', header: texts.manageByUser.columnStatus, render: r => {r.status} }, { key: 'phase', header: texts.manageByUser.columnPhase, render: r => r.phase }, { key: 'actions', header: '', sticky: true, render: r => ( runReplace({ live: [r], templates: [] })} /> ) } ]; const templateColumns: IColumn[] = [ { key: 'check', header: '', width: '32px', render: r => toggleTemplate(r)} /> }, { key: 'route', header: texts.manageByUser.columnRoute, render: r => {`${r.requestTypeName} — ${r.companyName} / ${r.departmentName}`} }, { 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 => ( runReplace({ live: [], templates: [r] })} /> ) } ]; const hasAnyRows = liveRows.length > 0 || templateRows.length > 0; return (
runReplace({ live: liveRows, templates: templateRows })} > {texts.manageByUser.replaceEverywhere} } /> {/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно идёт в несколько приёмов (сначала заявки, потом маршруты, потом отдельные строки), и выбирать одного и того же человека заново на каждый приём незачем. */}
{/* Обратная замена — обычное продолжение работы («вернуть всё как было», «а теперь наоборот»), и переставлять двух человек руками через два поиска по справочнику ради этого не нужно. */}
{employeeEmail && (
{`${texts.manageByUser.liveTitle} (${liveRows.length})`}
{liveError && {`${texts.manageByUser.liveLoadError}: ${liveError}`}} {liveLoading &&
{texts.common.loading}
} {!liveLoading && !liveError && ( )}
)} {employeeEmail && (
{`${texts.manageByUser.templatesTitle} (${templateRows.length})`}
{templateSteps.error && {`${texts.manageByUser.templatesLoadError}: ${templateSteps.error}`}} {templateSteps.isLoading &&
{texts.common.loading}
} {!templateSteps.isLoading && !templateSteps.error && ( r.id} emptyMessage={texts.manageByUser.templatesEmpty} /> )}
)} {progress && ( )} {duplicatePrompt && ( )} {resultToast && ( setResultToast(undefined)} /> )}
); };