теперь нельзя дублировать
Tests / frontend-test (pull_request) Successful in 45s
Tests / backend-test (pull_request) Successful in 1m36s

This commit is contained in:
gorbenko.pavel
2026-08-10 09:50:03 +05:00
parent f775a9fdda
commit 89b7b4ae58
4 changed files with 35 additions and 2 deletions
@@ -10,6 +10,8 @@ export interface IManageRequestsApproverModalProps {
initialEmail?: string; initialEmail?: string;
employees: Employee[]; employees: Employee[];
employeesLoading: boolean; employeesLoading: boolean;
/** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */
excludedEmails: string[];
onSave: (email: string, position: string) => Promise<void>; onSave: (email: string, position: string) => Promise<void>;
onClose: () => void; onClose: () => void;
} }
@@ -27,18 +29,28 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
initialEmail = '', initialEmail = '',
employees, employees,
employeesLoading, employeesLoading,
excludedEmails,
onSave, onSave,
onClose onClose
}) => { }) => {
const [email, setEmail] = React.useState(initialEmail); const [email, setEmail] = React.useState(initialEmail);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
// Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат
// физически нельзя выбрать (см. excludedEmails в ManageRequestsScreen).
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
const handleSubmit = (): void => { const handleSubmit = (): void => {
const trimmedEmail = email.trim(); const trimmedEmail = email.trim();
if (!trimmedEmail) { if (!trimmedEmail) {
submit.fail(texts.manageRequests.validationApprover); submit.fail(texts.manageRequests.validationApprover);
return; return;
} }
if (excludedSet.has(trimmedEmail.toLowerCase())) {
submit.fail(texts.manageRequests.validationDuplicateApprover);
return;
}
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || ''; const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
submit.run(() => onSave(trimmedEmail, position)); submit.run(() => onSave(trimmedEmail, position));
}; };
@@ -48,7 +60,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
<FormGrid> <FormGrid>
<Field label={texts.manageRequests.approverLabel} required full> <Field label={texts.manageRequests.approverLabel} required full>
<SearchableSelect <SearchableSelect
options={employees.map(toEmployeeOption)} options={selectableEmployees.map(toEmployeeOption)}
value={email} value={email}
loading={employeesLoading} loading={employeesLoading}
placeholder={texts.manageRequests.approverPlaceholder} placeholder={texts.manageRequests.approverPlaceholder}
@@ -132,6 +132,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
// добавлять туда исполнителя всё равно бессмысленно). // добавлять туда исполнителя всё равно бессмысленно).
const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status))); const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status)));
// Один и тот же человек не может стоять в фазе дважды (в т.ч. призрачной
// строкой, которая ещё не подтверждена сервером, — см. handleAddApprover в
// ManageRequestsScreen) — исключаем уже занятых из пикера, а не проверяем
// постфактум: так дубликат физически нельзя выбрать.
const usedEmails = new Set(rows.map(r => r.approverEmail.toLowerCase()).filter(Boolean));
const addableEmployees = employees.filter(e => !usedEmails.has(e.email.toLowerCase()));
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
const renumbered = withOrdersFrom(mutate(withParallel), 0); const renumbered = withOrdersFrom(mutate(withParallel), 0);
const updates = diffOrders(rows, renumbered); const updates = diffOrders(rows, renumbered);
@@ -322,7 +329,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
{!readOnly && !phaseClosed && ( {!readOnly && !phaseClosed && (
<div className={styles.addRow}> <div className={styles.addRow}>
<SearchableSelect <SearchableSelect
options={employees.map(toEmployeeOption)} options={addableEmployees.map(toEmployeeOption)}
value="" value=""
loading={employeesLoading} loading={employeesLoading}
placeholder={addPlaceholder} placeholder={addPlaceholder}
@@ -349,6 +349,18 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const openReassign = (row: ManagedApprovalRow): void => setModal(row); const openReassign = (row: ManagedApprovalRow): void => setModal(row);
const closeModal = (): void => setModal(undefined); const closeModal = (): void => setModal(undefined);
// Кого нельзя выбрать при переназначении — все остальные согласующие/
// исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было
// бы оставить текущего человека на месте). Один человек не может стоять в
// фазе дважды — та же гарантия, что и addableEmployees в
// ManageRequestsPhaseTable для добавления нового шага.
const reassignExcludedEmails = matrix && modal
? matrix.rows
.filter(r => r.phase === modal.phase && r.id !== modal.id)
.map(r => r.approverEmail.toLowerCase())
.filter(Boolean)
: [];
// Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу // Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу
// сервера, без похода за всей матрицей заново. approverName берём из уже // сервера, без похода за всей матрицей заново. approverName берём из уже
// загруженного справочника сотрудников (тот же источник, что подставляет // загруженного справочника сотрудников (тот же источник, что подставляет
@@ -572,6 +584,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
initialEmail={modal.approverEmail} initialEmail={modal.approverEmail}
employees={employees.items} employees={employees.items}
employeesLoading={employees.isLoading} employeesLoading={employees.isLoading}
excludedEmails={reassignExcludedEmails}
onSave={saveModal} onSave={saveModal}
onClose={closeModal} onClose={closeModal}
/> />
@@ -321,6 +321,7 @@ export const texts = {
approverLabel: 'Сотрудник', approverLabel: 'Сотрудник',
approverPlaceholder: 'Выберите сотрудника', approverPlaceholder: 'Выберите сотрудника',
validationApprover: 'Выберите сотрудника.', validationApprover: 'Выберите сотрудника.',
validationDuplicateApprover: 'Этот сотрудник уже стоит в этой фазе на другом шаге.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (position: string): string => `Шаг «${position || 'без должности'}» будет удалён безвозвратно.`, deleteText: (position: string): string => `Шаг «${position || 'без должности'}» будет удалён безвозвратно.`,
orderFloorHint: (order: number): string => orderFloorHint: (order: number): string =>