копирование есть
This commit is contained in:
@@ -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 styles from './RouteStepFormModal.module.scss';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
Employee,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from '../../models';
|
||||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
@@ -186,6 +187,75 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 orderNo = Number(form.orderNo);
|
||||
if (!orderNo || orderNo < 1) {
|
||||
@@ -304,6 +374,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||
placeholder={texts.routeSteps.positionPlaceholder}
|
||||
/>
|
||||
{positionPreview}
|
||||
</Field>
|
||||
)}
|
||||
{form.kind === 'projectRole' && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RoutesScreen.module.scss';
|
||||
import { RouteFormModal } from './RouteFormModal';
|
||||
import { RouteCopyModal } from './RouteCopyModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { TemplateRoute } from '../../models';
|
||||
@@ -69,6 +70,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||
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);
|
||||
|
||||
@@ -145,6 +147,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<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="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</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 && (
|
||||
<ConfirmDialog
|
||||
title={texts.routes.deleteTitle}
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface IFormModalProps {
|
||||
/** Текст ошибки: и валидации формы, и ответа бэкенда. */
|
||||
error?: string;
|
||||
isSaving: boolean;
|
||||
/** Подпись кнопки подтверждения — по умолчанию «Сохранить» (см. копирование маршрута). */
|
||||
submitLabel?: string;
|
||||
/** Подпись той же кнопки во время запроса — по умолчанию «Сохранение...». */
|
||||
savingLabel?: string;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
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
|
||||
title={title}
|
||||
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="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||
{isSaving ? texts.common.saving : texts.common.save}
|
||||
{isSaving ? savingLabel : submitLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export type IconName =
|
||||
| 'briefcase'
|
||||
| 'flag'
|
||||
| 'crown'
|
||||
| 'refresh';
|
||||
| 'refresh'
|
||||
| 'copy';
|
||||
|
||||
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-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-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 {
|
||||
|
||||
@@ -33,4 +33,11 @@ export interface Employee {
|
||||
* подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для
|
||||
* отображения в списке (сам справочник должностей не показывает). */
|
||||
position: string;
|
||||
/**
|
||||
* Компания сотрудника — '' если не пришла из справочника. Нужна подсказке
|
||||
* «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ
|
||||
* (должность, компания инициатора), поэтому одну и ту же должность в разных
|
||||
* юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной.
|
||||
*/
|
||||
company: string;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ interface IPortalEmployee {
|
||||
fio: string;
|
||||
email: string | null;
|
||||
position: string | null;
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||
@@ -112,11 +113,17 @@ export class PortalDictionaryClient {
|
||||
if (!x.email) return;
|
||||
const existing = byEmail.get(x.email);
|
||||
if (!existing) {
|
||||
byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' });
|
||||
} else if (!existing.position && x.position) {
|
||||
// Один сотрудник — строка на каждый его отдел; должность могла найтись
|
||||
// не в первой из них.
|
||||
existing.position = x.position;
|
||||
byEmail.set(x.email, {
|
||||
fio: x.fio,
|
||||
email: x.email,
|
||||
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());
|
||||
|
||||
@@ -187,6 +187,38 @@ export const texts = {
|
||||
controlLabel: 'Контроль',
|
||||
controlCheckbox: 'Требуется отдельный контроль исполнения',
|
||||
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: 'Удалить маршрут?',
|
||||
deleteText: (routeId: number): string =>
|
||||
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
|
||||
@@ -232,6 +264,20 @@ export const texts = {
|
||||
executorPlaceholder: 'Выберите сотрудника',
|
||||
positionLabel: 'Должность',
|
||||
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: 'Главный инженер',
|
||||
projectRoleLabel: 'Роль в проекте',
|
||||
headTypeLabel: 'Тип руководителя',
|
||||
@@ -385,7 +431,10 @@ export const texts = {
|
||||
missingInDictionary: 'Нет в справочнике',
|
||||
loadingDictionary: 'Загрузка справочника...',
|
||||
searching: 'Поиск...',
|
||||
employeeSearchPlaceholder: 'Поиск по ФИО или email...',
|
||||
// Не перечисляем поля: искать можно и по ФИО, и по email, и по должности
|
||||
// (см. toEmployeeOption — должность попадает в sublabel, а SearchableSelect
|
||||
// ищет и по нему). Перечисление всё равно устарело бы при следующем поле.
|
||||
employeeSearchPlaceholder: 'Поиск...',
|
||||
employeesEmpty: 'Сотрудники не найдены в справочнике'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,3 +19,67 @@ export function findEmployeeByEmail(employees: Employee[], email: string | undef
|
||||
const target = email.toLowerCase();
|
||||
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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user