Редактирование заявок у конкретного пользователя (#14)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 7m18s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-08-19 12:58:07 +00:00
parent 82512eb1db
commit 1ee1b71e3e
36 changed files with 1862 additions and 333 deletions
@@ -69,6 +69,12 @@ export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNaviga
description={texts.dashboard.manageRequestsCard.description}
onClick={() => onNavigate('manageRequests')}
/>
<NavCard
icon="people"
title={texts.dashboard.manageByUserCard.title}
description={texts.dashboard.manageByUserCard.description}
onClick={() => onNavigate('manageByUser')}
/>
</CardGrid>
</div>
);
@@ -0,0 +1,34 @@
import * as React from 'react';
import { texts } from '../../texts';
import { Button, Modal } from '../ui';
export type DuplicateChoice = 'delete' | 'skip';
export interface IManageByUserDuplicateModalProps {
title: string;
message: string;
onChoice: (choice: DuplicateChoice, forAll: boolean) => void;
}
/**
* «Да / Да для всех / Нет / Нет для всех» — классический диалог разрешения
* конфликтов при пакетной операции (как в проводнике при копировании файлов).
* Закрытие крестиком/подложкой равносильно «Нет» (без «для всех» — иначе
* случайный клик мимо тихо остановил бы весь оставшийся список).
*/
export const ManageByUserDuplicateModal: React.FC<IManageByUserDuplicateModalProps> = ({ title, message, onChoice }) => (
<Modal
title={title}
onClose={() => onChoice('skip', false)}
footer={
<>
<Button variant="secondary" onClick={() => onChoice('skip', false)}>{texts.manageByUser.duplicateSkip}</Button>
<Button variant="secondary" onClick={() => onChoice('skip', true)}>{texts.manageByUser.duplicateSkipAll}</Button>
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', false)}>{texts.manageByUser.duplicateDelete}</Button>
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', true)}>{texts.manageByUser.duplicateDeleteAll}</Button>
</>
}
>
<p>{message}</p>
</Modal>
);
@@ -0,0 +1,36 @@
@import '../../../../styles/design-tokens';
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.panelTitle {
font-size: 13px;
font-weight: 600;
color: $gray-700;
}
.panelActions {
display: flex;
align-items: center;
gap: 8px;
}
// Поле «Заменить на» + кнопка «поменять местами» справа от него: кнопка стоит
// на уровне САМОГО контрола, а не подписи, поэтому живёт внутри Field, а не
// отдельной колонкой сетки.
.targetRow {
display: flex;
align-items: center;
gap: 8px;
}
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
// содержимого, и длинное ФИО в селекте вытолкнет кнопку за край поля.
.targetSelect {
flex: 1 1 auto;
min-width: 0;
}
@@ -0,0 +1,589 @@
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, 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 { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
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,
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<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api);
const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary);
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(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 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 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<DuplicateChoice | undefined>(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<DuplicateChoice> => {
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
return new Promise<DuplicateChoice>(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<IBulkOutcome> {
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),
{ 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<IBulkOutcome> {
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<void> => {
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<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} disabled={!canReplace} onClick={() => runReplace({ 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} disabled={!canReplace} onClick={() => runReplace({ 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={!canReplace || !hasAnyRows}
onClick={() => runReplace({ live: liveRows, templates: templateRows })}
>
{texts.manageByUser.replaceEverywhere}
</Button>
}
/>
<Panel>
<FormGrid>
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint}>
<SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={employeeEmail}
loading={employees.isLoading}
placeholder={texts.manageByUser.employeePlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={selectEmployee}
/>
</Field>
{/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно
идёт в несколько приёмов (сначала заявки, потом маршруты, потом
отдельные строки), и выбирать одного и того же человека заново
на каждый приём незачем. */}
<Field label={texts.manageByUser.targetLabel} required hint={texts.manageByUser.targetHint}>
<div className={styles.targetRow}>
<div className={styles.targetSelect}>
<SearchableSelect
options={targetOptions}
value={targetEmail}
loading={employees.isLoading}
placeholder={texts.manageByUser.targetPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
allowClear
clearLabel={texts.manageByUser.targetClear}
onChange={selectTarget}
/>
</div>
{/* Обратная замена — обычное продолжение работы («вернуть всё
как было», «а теперь наоборот»), и переставлять двух человек
руками через два поиска по справочнику ради этого не нужно. */}
<IconButton
icon="sort"
label={texts.manageByUser.swap}
disabled={!employeeEmail && !targetEmail}
onClick={swapEmployees}
/>
</div>
</Field>
</FormGrid>
</Panel>
{employeeEmail && (
<Panel>
<div className={styles.panelHeader}>
<div className={styles.panelTitle}>{`${texts.manageByUser.liveTitle} (${liveRows.length})`}</div>
<div className={styles.panelActions}>
<Button variant="ghost" size="sm" disabled={liveRows.length === 0} onClick={toggleAllLive}>
{allLiveChecked ? texts.manageByUser.deselectAll : texts.manageByUser.selectAll}
</Button>
<Button
variant="secondary"
size="sm"
disabled={!canReplace || checkedLiveRows.length === 0}
onClick={() => runReplace({ live: checkedLiveRows, templates: [] })}
>
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
</Button>
</div>
</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>
<div className={styles.panelActions}>
<Button variant="ghost" size="sm" disabled={templateRows.length === 0} onClick={toggleAllTemplates}>
{allTemplatesChecked ? texts.manageByUser.deselectAll : texts.manageByUser.selectAll}
</Button>
<Button
variant="secondary"
size="sm"
disabled={!canReplace || checkedTemplateRows.length === 0}
onClick={() => runReplace({ live: [], templates: checkedTemplateRows })}
>
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
</Button>
</div>
</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>
)}
{progress && (
<ProgressOverlay label={texts.manageByUser.progressLabel} done={progress.done} total={progress.total} />
)}
{duplicatePrompt && (
<ManageByUserDuplicateModal
title={texts.manageByUser.duplicateModalTitle}
message={duplicatePrompt.message}
onChoice={answerDuplicate}
/>
)}
{resultToast && (
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
)}
</div>
);
};
@@ -15,6 +15,7 @@ import {
} from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail } from '../../utils/employee';
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
route?: IManageRequestRouteParams;
}
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
// либо список данных этой колонки не заводит).
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
}
// Цвет плашки статуса — те же группы смысла, что и в request-portal
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
function statusVariant(status: string): BadgeVariant {
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!route) return;
setSelection({
requestTypeId: route.requestTypeId,
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
// listId из ссылки — какая партиция, если ссылку скопировали уже после
// этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже
// подставит текущую партицию (тот же эффект, что был раньше, но теперь
// явный, а не единственно возможный).
selectedRequest: {
id: Number(route.requestId),
title: '',
status: '',
created: '',
authorName: '',
uniqueCode: null,
listId: route.listId
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
// состояния текущего экрана, а не переход на новую страницу с отдельным
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
// сам requestId, хранить производные данные в ссылке незачем.
// сам requestId, хранить производные данные в ссылке незачем. listId —
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
// искать requestId (см. IManageRequestRouteParams).
React.useEffect(() => {
replaceRoute({
tab: 'manageRequests',
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
manage: selectedRequest
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
: undefined
});
}, [requestTypeId, selectedRequest]);
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// и читать заявки этого типа отсюда просто негде.
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
// нужно по всем сразу, не только по текущей.
const partitions = useRequestTypeListPartitions(api, requestType?.id);
const selectRequestType = (id: string): void => {
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
// показывать заявку не того типа, что выбран сейчас.
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback(
(query: string, page: number) => {
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
return requestManage.searchRequests(requestType, query, page);
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
return Promise.resolve({ items: [], hasMore: false });
}
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
},
[requestManage, requestType]
[requestManage, requestType, partitions.items]
);
// silent=true — фоновое обновление (после своего же действия, автообновление
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const reloadMatrix = React.useCallback(
(silent = false) => {
if (!requestType || !selectedRequest) return;
// Партиции ещё не подтянулись (первый рендер после смены типа/захода по
// ссылке) — без них нельзя даже подставить запасной вариант (текущая
// партиция) для ссылок без listId, ждём следующего рендера.
if (partitions.isLoading) return;
if (!requestType.siteUrl) {
setMatrixError('У типа заявки не указан список SharePoint.');
return;
}
// listId заявки уже известен (выбрана из комбобокса, либо ссылка после
// этого фикса) — используем его. Иначе (старая ссылка без listId) —
// единственный разумный запасной вариант: текущая партиция, тот же
// список, что искался бы и раньше.
const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId;
if (!listId) {
setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.');
return;
}
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
requestManage
.getMatrix(requestType, selectedRequest.id)
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status в комбобоксе (а значит, и в localStorage) держим
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
// синхронными с сервером — особенно важно после захода по глубокой
// ссылке, где изначально они пустые (см. эффект-затравку выше).
// ссылке, где изначально они пустые (см. эффект-затравку выше), и
// после подстановки запасной (текущей) партиции выше: как только
// реальная партиция заявки известна, оседает в выборе — следующий
// reloadMatrix пойдёт уже по ней напрямую.
//
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
// отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ответа), и если писать его в состояние безусловно, selectedRequest
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
setSelection(prev => {
const sel = prev.selectedRequest;
if (!sel || sel.id !== m.request.id) return prev;
const same = sel.title === m.request.title && sel.status === m.request.status;
const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId;
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!silent) setMatrixLoading(false);
});
},
[requestManage, requestType, selectedRequest]
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
);
React.useEffect(() => {
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<AsyncSearchableSelect<ManagedRequestSummary>
key={requestTypeId || 'none'}
value={selectedRequest ? String(selectedRequest.id) : ''}
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
selectedSublabel={selectedRequest?.status}
disabled={!requestType}
placeholder={texts.manageRequests.requestPlaceholder}
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
search={searchRequests}
getValue={r => String(r.id)}
getLabel={r => `#${r.id} ${r.title}`}
getLabel={requestLabel}
getSublabel={r => r.status}
onChange={(_, item) => selectRequest(item)}
/>