590 lines
28 KiB
TypeScript
590 lines
28 KiB
TypeScript
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>
|
||
);
|
||
};
|