Обходной лист #12
@@ -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());
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user