копирование есть

This commit is contained in:
gorbenko.pavel
2026-08-14 14:03:38 +05:00
parent 10f70609ad
commit d052fd41b8
10 changed files with 540 additions and 10 deletions
@@ -0,0 +1,229 @@
import * as React from 'react';
import {
Company,
CreateTemplateRouteStepInput,
Department,
ProjectRole,
TemplateRoute,
TemplateRouteStep
} from '../../models';
import { texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { ApprovalRouteApi } from '../../services/api';
import { ApiClientError } from '../../services/httpClient';
import { Alert, Checkbox, Field, FormGrid, FormModal, SearchableSelect } from '../ui';
export interface IRouteCopyModalProps {
api: ApprovalRouteApi;
/** Маршрут-источник: тип заявки у копии тот же, меняются только «координаты». */
source: TemplateRoute;
requestTypeName: string;
companies: Company[];
departments: Department[];
dictionaryLoading: boolean;
onCopied: () => void;
onClose: () => void;
}
interface IFormState {
companyId: string;
departmentId: string;
byProject: boolean;
requiresControl: boolean;
}
/** Тело для создания шага-копии: всё, кроме id/routeId, переносится как есть. */
function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
return {
orderNo: step.orderNo,
executorEmail: step.executorEmail,
position: step.position,
projectRole: step.projectRole,
headType: step.headType,
status: step.status,
allowReassignToOtherApprovers: step.allowReassignToOtherApprovers,
fieldName: step.fieldName,
operation: step.operation,
value: step.value,
slaHours: step.slaHours,
askOverdueReason: step.askOverdueReason
};
}
/**
* Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки.
*
* Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см.
* HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому
* копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это
* до запроса, чтобы вместо 409 от бэкенда показать понятную причину.
*
* Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ
* УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы
* молча резолвился с недостающими согласующими.
*/
export const RouteCopyModal: React.FC<IRouteCopyModalProps> = ({
api,
source,
requestTypeName,
companies,
departments,
dictionaryLoading,
onCopied,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>({
companyId: source.companyId ?? '',
departmentId: source.departmentId ?? '',
byProject: source.byProject,
requiresControl: source.requiresControl
});
const steps = useRouteSteps(api, source.id);
const submit = useAsyncSubmit();
const departmentsForSelectedCompany = React.useMemo(
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
[departments, form.companyId]
);
const sourceCompany = source.companyId
? companies.find(c => c.id === source.companyId)?.name ?? shortGuid(source.companyId)
: texts.routeSteps.anyCompany;
const sourceDepartment = source.departmentId
? departments.find(d => d.id === source.departmentId)?.name ?? shortGuid(source.departmentId)
: texts.routeSteps.anyDepartment;
// Шаги с ролью в проекте бэкенд не примет в маршрут без признака «по проекту».
const projectRoleSteps = steps.items.filter(s => s.projectRole && s.projectRole !== ProjectRole.None).length;
const projectRoleBlocks = projectRoleSteps > 0 && !form.byProject;
// Копия должна отличаться от источника: тип заявки у них общий, значит
// различие обязано быть в компании, отделе или признаке «по проекту».
const sameAsSource =
(form.companyId || '') === (source.companyId ?? '') &&
(form.departmentId || '') === (source.departmentId ?? '') &&
form.byProject === source.byProject;
const submitForm = (): void => {
if (sameAsSource) {
submit.fail(texts.routes.validationCopySame);
return;
}
if (projectRoleBlocks) {
submit.fail(texts.routes.copyProjectRoleWarning(projectRoleSteps));
return;
}
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
submit.run(async () => {
const created = await api.templateRoutes.create({
requestTypeId: source.requestTypeId,
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
});
// Последовательно, а не Promise.all: при ошибке важно знать, на каком шаге
// сорвались, а параллельные запросы к тому же дают гонку по orderNo в логах.
try {
for (const step of [...steps.items].sort((a, b) => a.orderNo - b.orderNo)) {
await api.templateRouteSteps.create(created.id, toStepInput(step)).catch((e: ApiClientError) => {
throw new Error(texts.routes.copyStepFailed(step.orderNo, e.message));
});
}
} catch (stepError) {
// Откат: маршрут без части шагов резолвился бы молча и неверно.
try {
await api.templateRoutes.remove(created.id);
} catch {
throw new Error(
`${(stepError as Error).message} ${texts.routes.copyRollbackFailed(created.id)}`
);
}
throw new Error(`${(stepError as Error).message} ${texts.routes.copyRolledBack}`);
}
onCopied();
onClose();
});
};
return (
<FormModal
title={texts.routes.copyTitle(source.id)}
error={submit.error}
isSaving={submit.isBusy}
submitLabel={texts.routes.copyAction}
savingLabel={texts.routes.copyInProgress}
onSubmit={submitForm}
onClose={onClose}
>
<Alert kind="ok">{texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)}</Alert>
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{!steps.error && steps.isLoading && <Alert kind="ok">{texts.routes.copyStepsLoading}</Alert>}
{!steps.error && !steps.isLoading && (
<Alert kind={steps.items.length > 0 ? 'ok' : 'warn'}>
{steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty}
</Alert>
)}
{projectRoleBlocks && <Alert kind="warn">{texts.routes.copyProjectRoleWarning(projectRoleSteps)}</Alert>}
<FormGrid>
<Field label={texts.routes.companyLabel} full hint={texts.routes.copyHint}>
<SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId}
loading={dictionaryLoading}
allowClear
clearLabel={texts.routes.companyClear}
placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId =>
// Как и в форме маршрута: смена компании обнуляет отдел — прежний
// принадлежал другой компании.
setForm({ ...form, companyId, departmentId: '' })
}
/>
</Field>
<Field
label={texts.routes.departmentLabel}
full
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
>
<SearchableSelect
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
value={form.departmentId}
loading={dictionaryLoading}
disabled={!form.companyId}
allowClear
clearLabel={texts.routes.departmentPlaceholder}
placeholder={texts.routes.departmentPlaceholder}
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
emptyMessage={texts.routes.departmentEmpty}
onChange={departmentId => setForm({ ...form, departmentId })}
/>
</Field>
<Field label={texts.routes.byProjectLabel}>
<Checkbox
label={texts.routes.byProjectCheckbox}
checked={form.byProject}
onChange={v => setForm({ ...form, byProject: v })}
/>
</Field>
<Field label={texts.routes.controlLabel}>
<Checkbox
label={texts.routes.controlCheckbox}
checked={form.requiresControl}
onChange={v => setForm({ ...form, requiresControl: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};
@@ -0,0 +1,72 @@
@import '../../../../styles/design-tokens';
// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри
// Field, поэтому отступ сверху небольшой — визуально это продолжение поля,
// а не отдельный блок.
.positionPreview {
margin-top: 6px;
padding: 8px 10px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-size: 12px;
line-height: 1.5;
}
// Найдено — зелёная рамка: должность рабочая, шаг резолвится.
.previewOk {
border-color: $green-600;
background: rgba($green-600, .06);
}
// Не найдено — предупреждение: шаг останется без исполнителя.
.previewMiss {
border-color: $gold-500;
background: $gold-50;
}
.previewTitle {
color: $gray-700;
}
.previewList {
margin: 6px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
}
.previewPerson {
color: $gray-900;
}
.previewCompany {
color: $gray-500;
}
// Похожая должность — кнопка: клик подставляет её точное написание в поле,
// чтобы не переписывать вручную и не ошибиться в регистре/пробелах.
.previewSuggest {
display: block;
width: 100%;
padding: 4px 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: 12px;
color: $navy-700;
cursor: pointer;
&:hover {
background: #fff;
color: $gold-600;
}
}
.previewSuggestCount {
color: $gray-500;
}
@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RouteStepFormModal.module.scss';
import { import {
ComparisonOperator, ComparisonOperator,
Employee, Employee,
@@ -12,7 +13,7 @@ import {
} from '../../models'; } from '../../models';
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee'; import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
@@ -186,6 +187,75 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isManagerField]); }, [isManagerField]);
// Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ
// совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо
// оставляет шаг без исполнителя — до самого резолва об этом узнать негде.
// Считаем по уже загруженному справочнику сотрудников, без запросов на ввод.
const positionMatches = React.useMemo(
() => matchEmployeesByPosition(employees, form.position),
[employees, form.position]
);
const positionPreview = ((): React.ReactNode => {
if (!form.position.trim()) return undefined;
if (employeesLoading) {
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewLoading}</div>;
}
// Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это
// сказало бы неправду про корректную должность.
if (employees.length === 0) {
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewNoDictionary}</div>;
}
if (positionMatches.exact.length > 0) {
return (
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
<span className={styles.previewTitle}>
{texts.routeSteps.positionPreviewFound(positionMatches.exact.length)}
</span>
<ul className={styles.previewList}>
{positionMatches.exact.map(e => (
<li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span>
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
</li>
))}
</ul>
</div>
);
}
return (
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
<span className={styles.previewTitle}>{texts.routeSteps.positionPreviewNone}</span>
{positionMatches.similar.length > 0 && (
<>
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewSimilar}</div>
<ul className={styles.previewList}>
{positionMatches.similar.map(s => (
<li key={s.position}>
<button
type="button"
className={styles.previewSuggest}
onClick={() => setForm(f => ({ ...f, position: s.position }))}
>
{s.position} — {s.fio}
{s.count > 1 && (
<span className={styles.previewSuggestCount}>
{' '}
({texts.routeSteps.positionPreviewMore(s.count - 1)})
</span>
)}
</button>
</li>
))}
</ul>
</>
)}
</div>
);
})();
const submitForm = (): void => { const submitForm = (): void => {
const orderNo = Number(form.orderNo); const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) { if (!orderNo || orderNo < 1) {
@@ -304,6 +374,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={e => setForm({ ...form, position: e.target.value })} onChange={e => setForm({ ...form, position: e.target.value })}
placeholder={texts.routeSteps.positionPlaceholder} placeholder={texts.routeSteps.positionPlaceholder}
/> />
{positionPreview}
</Field> </Field>
)} )}
{form.kind === 'projectRole' && ( {form.kind === 'projectRole' && (
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RoutesScreen.module.scss'; import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal'; import { RouteFormModal } from './RouteFormModal';
import { RouteCopyModal } from './RouteCopyModal';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { TemplateRoute } from '../../models'; import { TemplateRoute } from '../../models';
@@ -69,6 +70,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
const [editing, setEditing] = React.useState<TemplateRoute | undefined>(); const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload); const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
@@ -145,6 +147,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
render: r => ( render: r => (
<ActionsCell> <ActionsCell>
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button> <Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
<IconButton icon="copy" label={texts.routes.copy} onClick={() => setCopying(r)} />
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} /> <IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} /> <IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell> </ActionsCell>
@@ -170,6 +173,19 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
/> />
)} )}
{copying && (
<RouteCopyModal
api={api}
source={copying}
requestTypeName={requestTypeName(copying.requestTypeId)}
companies={portalDictionaries.companies}
departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading}
onCopied={routes.reload}
onClose={() => setCopying(undefined)}
/>
)}
{deleteTarget.target && ( {deleteTarget.target && (
<ConfirmDialog <ConfirmDialog
title={texts.routes.deleteTitle} title={texts.routes.deleteTitle}
@@ -9,6 +9,10 @@ export interface IFormModalProps {
/** Текст ошибки: и валидации формы, и ответа бэкенда. */ /** Текст ошибки: и валидации формы, и ответа бэкенда. */
error?: string; error?: string;
isSaving: boolean; isSaving: boolean;
/** Подпись кнопки подтверждения — по умолчанию «Сохранить» (см. копирование маршрута). */
submitLabel?: string;
/** Подпись той же кнопки во время запроса — по умолчанию «Сохранение...». */
savingLabel?: string;
onSubmit: () => void; onSubmit: () => void;
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
@@ -18,7 +22,16 @@ export interface IFormModalProps {
* Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу» * Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу»
* для всех справочников, чтобы экраны описывали только поля. * для всех справочников, чтобы экраны описывали только поля.
*/ */
export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, onSubmit, onClose, children }) => ( export const FormModal: React.FC<IFormModalProps> = ({
title,
error,
isSaving,
submitLabel = texts.common.save,
savingLabel = texts.common.saving,
onSubmit,
onClose,
children
}) => (
<Modal <Modal
title={title} title={title}
onClose={onClose} onClose={onClose}
@@ -26,7 +39,7 @@ export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, o
<> <>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button> <Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}> <Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? texts.common.saving : texts.common.save} {isSaving ? savingLabel : submitLabel}
</Button> </Button>
</> </>
} }
@@ -24,7 +24,8 @@ export type IconName =
| 'briefcase' | 'briefcase'
| 'flag' | 'flag'
| 'crown' | 'crown'
| 'refresh'; | 'refresh'
| 'copy';
let spriteInjected = false; let spriteInjected = false;
@@ -51,6 +52,7 @@ const SPRITE_SVG = `
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol> <symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol> <symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol> <symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></symbol>
`; `;
function ensureSpriteInjected(): void { function ensureSpriteInjected(): void {
@@ -33,4 +33,11 @@ export interface Employee {
* подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для * подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для
* отображения в списке (сам справочник должностей не показывает). */ * отображения в списке (сам справочник должностей не показывает). */
position: string; position: string;
/**
* Компания сотрудника — '' если не пришла из справочника. Нужна подсказке
* «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ
* (должность, компания инициатора), поэтому одну и ту же должность в разных
* юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной.
*/
company: string;
} }
@@ -53,6 +53,7 @@ interface IPortalEmployee {
fio: string; fio: string;
email: string | null; email: string | null;
position: string | null; position: string | null;
company: string | null;
} }
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> { async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
@@ -112,11 +113,17 @@ export class PortalDictionaryClient {
if (!x.email) return; if (!x.email) return;
const existing = byEmail.get(x.email); const existing = byEmail.get(x.email);
if (!existing) { if (!existing) {
byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' }); byEmail.set(x.email, {
} else if (!existing.position && x.position) { fio: x.fio,
// Один сотрудник — строка на каждый его отдел; должность могла найтись email: x.email,
// не в первой из них. position: x.position || '',
existing.position = x.position; company: x.company || ''
});
} else {
// Один сотрудник — строка на каждый его отдел; должность и компания
// могли найтись не в первой из них.
if (!existing.position && x.position) existing.position = x.position;
if (!existing.company && x.company) existing.company = x.company;
} }
}); });
return Array.from(byEmail.values()); return Array.from(byEmail.values());
+50 -1
View File
@@ -187,6 +187,38 @@ export const texts = {
controlLabel: 'Контроль', controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения', controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.', validationRequestType: 'Выберите тип заявки.',
// ---- Копирование маршрута ----
copy: 'Копировать',
copyTitle: (routeId: number): string => `Копия маршрута #${routeId}`,
copyAction: 'Создать копию',
copyInProgress: 'Копирование...',
copyHint:
'Копия создаётся для того же типа заявки вместе со всеми шагами. Маршрут различается по компании, ' +
'отделу и признаку «по проекту» — измените хотя бы одно из них, иначе копия совпадёт с исходным маршрутом.',
copySource: (requestTypeName: string, company: string, department: string): string =>
`Копируем: ${requestTypeName} · ${company} · ${department}`,
copyStepsCount: (count: number): string =>
`Шагов будет скопировано: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])}`,
copyStepsLoading: 'Загрузка шагов исходного маршрута...',
copyStepsEmpty: 'В исходном маршруте нет шагов — копия будет пустой.',
validationCopySame:
'Копия совпадает с исходным маршрутом. Измените компанию, отдел или признак «по проекту» — ' +
'иначе такой маршрут уже существует.',
// Роль в проекте допустима только у маршрута с признаком «по проекту» (проверка
// на бэкенде, TemplateRouteStepService) — предупреждаем ДО копирования, иначе
// копия сорвётся на середине переноса шагов.
copyProjectRoleWarning: (count: number): string =>
`В исходном маршруте ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} с ролью в проекте. ` +
'Без признака «по проекту» такие шаги скопировать нельзя — включите его или удалите эти шаги в исходном маршруте.',
copyStepFailed: (orderNo: number, message: string): string =>
`Не удалось скопировать шаг ${orderNo}: ${message}`,
// Частично скопированный маршрут опаснее, чем несозданный: он бы резолвился
// с недостающими согласующими. Поэтому при ошибке копию удаляем целиком.
copyRolledBack: 'Копия не создана — маршрут удалён, чтобы не остался наполовину скопированным.',
copyRollbackFailed: (routeId: number): string =>
`ВНИМАНИЕ: маршрут #${routeId} создан, но шаги скопированы не полностью, и удалить его автоматически не вышло. Удалите маршрут вручную.`,
deleteTitle: 'Удалить маршрут?', deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string => deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.` `Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
@@ -232,6 +264,20 @@ export const texts = {
executorPlaceholder: 'Выберите сотрудника', executorPlaceholder: 'Выберите сотрудника',
positionLabel: 'Должность', positionLabel: 'Должность',
positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.', positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.',
// Подсказка «кого найдёт эта должность» — считается по загруженному справочнику.
positionPreviewLoading: 'Проверяем по справочнику сотрудников...',
positionPreviewFound: (count: number): string =>
count === 1
? 'Сейчас эту должность занимает:'
: `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` +
'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:',
positionPreviewNone:
'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.',
positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):',
/** Сколько ещё человек занимают ту же должность, кроме показанного. */
positionPreviewMore: (count: number): string =>
`и ещё ${count} ${pluralRu(count, ['человек', 'человека', 'человек'])}`,
positionPreviewNoDictionary: 'Справочник сотрудников недоступен — проверить должность не удалось.',
positionPlaceholder: 'Главный инженер', positionPlaceholder: 'Главный инженер',
projectRoleLabel: 'Роль в проекте', projectRoleLabel: 'Роль в проекте',
headTypeLabel: 'Тип руководителя', headTypeLabel: 'Тип руководителя',
@@ -385,7 +431,10 @@ export const texts = {
missingInDictionary: 'Нет в справочнике', missingInDictionary: 'Нет в справочнике',
loadingDictionary: 'Загрузка справочника...', loadingDictionary: 'Загрузка справочника...',
searching: 'Поиск...', searching: 'Поиск...',
employeeSearchPlaceholder: 'Поиск по ФИО или email...', // Не перечисляем поля: искать можно и по ФИО, и по email, и по должности
// (см. toEmployeeOption — должность попадает в sublabel, а SearchableSelect
// ищет и по нему). Перечисление всё равно устарело бы при следующем поле.
employeeSearchPlaceholder: 'Поиск...',
employeesEmpty: 'Сотрудники не найдены в справочнике' employeesEmpty: 'Сотрудники не найдены в справочнике'
} }
}; };
@@ -19,3 +19,67 @@ export function findEmployeeByEmail(employees: Employee[], email: string | undef
const target = email.toLowerCase(); const target = email.toLowerCase();
return employees.find(e => e.email.toLowerCase() === target); return employees.find(e => e.email.toLowerCase() === target);
} }
export interface SimilarPosition {
/** Написание должности как в справочнике — его и подставляем по клику. */
position: string;
/**
* ФИО первого найденного сотрудника с этой должностью — по одному названию
* не всегда понятно, тот ли это человек, что нужен; имя снимает вопрос сразу,
* без подстановки и повторной проверки.
*/
fio: string;
/** Сколько сотрудников её занимают (в разных компаниях/отделах). */
count: number;
}
export interface PositionMatches {
/**
* Кого бэкенд реально подставит: он ищет ТОЧНОЕ совпадение должности
* (/Employee/ByPosition), поэтому здесь тоже точное — с точностью до регистра
* и краевых пробелов. Несколько человек — норма: должность одна, а компании
* разные, и на resolve выберется тот, чья компания совпала с компанией инициатора.
*/
exact: Employee[];
/**
* Похожие должности — на случай опечатки или неполного ввода. Уникальные
* по написанию, а не по сотруднику: список нужен, чтобы подставить в поле
* точный текст должности, и пять одинаковых строк там бесполезны.
* Точное совпадение сюда не попадает — оно уже показано в exact.
*/
similar: SimilarPosition[];
}
/** Максимум похожих в подсказке — длинный список её только зашумит. */
const SIMILAR_LIMIT = 6;
/**
* Кого найдёт шаг «по должности в компании инициатора» с таким текстом должности.
* Считается по уже загруженному справочнику (без запроса на каждый символ).
*/
export function matchEmployeesByPosition(employees: Employee[], position: string): PositionMatches {
const query = position.trim().toLowerCase();
if (!query) return { exact: [], similar: [] };
const exact: Employee[] = [];
// Ключ — нормализованное написание, значение — как показать и сколько человек.
const similarByKey = new Map<string, SimilarPosition>();
employees.forEach(e => {
const raw = e.position.trim();
const candidate = raw.toLowerCase();
if (!candidate) return;
if (candidate === query) {
exact.push(e);
return;
}
if (candidate.indexOf(query) === -1) return;
const existing = similarByKey.get(candidate);
if (existing) existing.count += 1;
else similarByKey.set(candidate, { position: raw, fio: e.fio, count: 1 });
});
return { exact, similar: Array.from(similarByKey.values()).slice(0, SIMILAR_LIMIT) };
}