Слияние main в feature/vacation-edo-signing: usePhasedRoute из main с editable-исполнителями, экспорты useRouteExecutors сохранены
Tests / frontend-test (pull_request) Successful in 1m25s

This commit is contained in:
gorbenko.pavel
2026-10-05 12:16:27 +05:00
70 changed files with 4721 additions and 22 deletions
+1
View File
@@ -49,6 +49,7 @@
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
@@ -25,6 +25,14 @@ export const REQUEST_TYPES: IRequestType[] = [
icon: 'user-check',
comingSoon: true
},
// Пока только тестировщикам: тип заявки и маршрут ещё не заведены в админке approval-route.
{
id: 'hr-business-trip',
categoryId: 'hr',
title: 'Заявка на командировку',
icon: 'vehicle',
comingSoon: true
},
{ id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true },
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', comingSoon: true },
{
@@ -0,0 +1,20 @@
@import '../../../../styles/design-tokens';
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,280 @@
import * as React from 'react';
import { useState, useMemo, useEffect } from 'react';
import styles from './BusinessTripForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
FieldIssuesProvider,
issueLabels,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
notifyActiveStep,
useRequestTypeId,
usePhasedRoute,
REQUEST_STATUS,
isEditableStatus,
initialSubmitStatus,
IApproverToWrite,
IUseMatrixEditor,
unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback';
import { submitDateOnOpen } from '../../shared/submitDate';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { draftIssues, submitIssues } from './validation';
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
import { loadBusinessTripRequest } from './load';
import { useTripEmployee } from './useTripEmployee';
import { EmployeeSection } from './sections/EmployeeSection';
import { RouteSection } from './sections/RouteSection';
import { TasksSection } from './sections/TasksSection';
import { EstimateSection } from './sections/EstimateSection';
import { ObligationSection } from './sections/ObligationSection';
const toApprovers = (rows: IUseMatrixEditor['rows']): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
// Транзитом из маршрута в строку списка: наследование бэкенд применил при resolve.
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
const BusinessTripForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
// Стабильная обёртка: ссылка onCodeLoaded у родителя меняется на каждом
// рендере, а в зависимостях эффекта загрузки она перезапрашивала бы заявку.
const notifyCode = useStableCallback(onCodeLoaded);
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const handleEmployeeChange = useTripEmployee(context, editId, setData);
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Открыта своя заявка на редактирование (черновик/возврат).
useEffect(() => {
if (!editId) return;
let alive = true;
loadBusinessTripRequest(editId, editListId)
.then((loaded) => {
if (!alive) return;
setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) });
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
})
.catch((e) => {
if (alive) setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Маршрут по выбранному сотруднику; у открытой заявки — из уже записанных строк,
// чтобы не затереть правки инициатора.
const { approvers: matrix, executors } = usePhasedRoute({
context,
approval,
requestId: editId,
employeeEmail: data.Employee,
requestTypeId,
editable: !readOnly
});
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
const draftMissing = draftIssues(data);
const submitMissing = submitIssues(data);
const validate = (): string | undefined => {
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
if (submitMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(submitMissing)}.`;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) =>
`Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
return unassignedStepMessage(
executors.rows,
(r) =>
`Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS не видит этого через замыкание.
const rtId = requestTypeId;
if (!rtId) throw new Error('Тип заявки не определён — обновите страницу и повторите.');
// Текущую партицию спрашиваем прямо перед записью: этот вызов запускает архивацию по порогу.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveBusinessTripRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status,
writeListId
);
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
if (onSaved) onSaved();
// Отправка (на согласование или, без согласующих, сразу на исполнение) — уведомляем активный шаг.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
// Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение».
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(initialSubmitStatus(matrix.rows.length, executors.rows.length)), validateSubmit);
const handleDelete = getDraftDeleteHandler({
formKey: FORM_ID,
requestId,
requestStatus,
listId: editListId,
code: requestCode,
submit,
onDeleted: onCancel
});
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
return (
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard
title={FORM_TITLE}
subtitle="Категория: Отдел кадров"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
{requestStatus && <StatusBadge status={requestStatus} />}
{requestCode && <RequestNumber code={requestCode} />}
</>
) : undefined
}
>
{submit.savedKind && !submit.error && (
<Alert variant="ok">
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
</Alert>
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} />
<RouteSection {...sectionProps} />
<TasksSection {...sectionProps} />
<EstimateSection {...sectionProps} />
<ObligationSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или отклонили. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
isValid={draftMissing.length === 0}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
submitLabel="Отправить на согласование"
/>
</FormCard>
</FieldIssuesProvider>
);
};
export default BusinessTripForm;
@@ -0,0 +1,17 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,188 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './BusinessTripView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
import {
ApproverService,
ApprovalMatrix,
DecisionPanel,
StatusBadge,
ApprovalHistory,
notifyActiveStep,
useRequestTypeId,
usePhasedRoute
} from '../../shared/approval';
import type { ISiteUser } from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { loadBusinessTripRequest } from './load';
import { businessTripApprovalService } from './submit';
import { EmployeeSection } from './sections/EmployeeSection';
import { RouteSection } from './sections/RouteSection';
import { TasksSection } from './sections/TasksSection';
import { EstimateSection } from './sections/EstimateSection';
import { ObligationSection } from './sections/ObligationSection';
interface IBusinessTripViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит эта заявка — см. IApprovalViewProps.listId. */
listId?: string;
onCodeLoaded?: (code: string) => void;
}
// В режиме чтения поля не редактируются — обновление пустышка.
const NOOP_SET: SetField = () => undefined;
/**
* Заявка на командировку для согласующего (только чтение). Печать — данные
* заявки и маршрут согласования/исполнения вместо подписей бланка; шапки с
* адресатом нет (решение Тимура: бумажный бланк один в один не повторяем).
*/
const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const approval = useMemo(() => businessTripApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const { approvers, executors } = usePhasedRoute({
context,
approval,
requestId: itemId,
employeeEmail: data.Employee,
requestTypeId,
editable: false,
reloadSeq: decisionSeq
});
useEffect(() => {
let alive = true;
setLoading(true);
loadBusinessTripRequest(itemId, listId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setError(null);
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
});
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const notify = (): void => {
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
const sectionProps = { data, set: NOOP_SET, context };
const searchUsers = (query: string): Promise<ISiteUser[]> => approval.searchUsers(query);
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintButton />
</div>
<div className="rp-print-root">
<FormCard
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<EmployeeSection {...sectionProps} />
<RouteSection {...sectionProps} />
<TasksSection {...sectionProps} />
<EstimateSection {...sectionProps} />
<ObligationSection {...sectionProps} />
</FormModeProvider>
</FormCard>
<ApprovalMatrix editor={approvers} search={searchUsers} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={searchUsers}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
)}
</div>
{/* История согласования: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={searchUsers}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notify();
}}
/>
</div>
</div>
);
};
export default BusinessTripView;
@@ -0,0 +1,82 @@
# Заявка на командировку (hr-business-trip)
**Назначение:** заявка о направлении сотрудника в служебную командировку — куда, когда,
с каким заданием и сметой расходов.
**Категория:** Отдел кадров (плитка «Заявка на командировку», пока `comingSoon` — только тестировщикам)
**Списки SP:** «Заявка на командировку — данные» / «Заявка на командировку — согласование»
**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при заведении
типа в админке approval-route с `pageUrl` «hr-business-trip».
## Маршрут
- Маршрут настраивается в админке approval-route другим человеком — форма его только получает.
- Фазы: согласование + исполнение, обе грузятся общим `usePhasedRoute` (shared/approval).
Нет шагов исполнения — после согласования статус «Согласовано»; нет согласующих —
`initialSubmitStatus` сразу ставит «Выполнение».
- Планируемый маршрут резолвится по e-mail **выбранного сотрудника**; согласующих заявитель
может править (новая / черновик / доработка), исполнителей — нет.
## Особое поведение
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
«дни · объекты · переезды · итог». Клик по точке — форма с прокруткой к ней. Заявитель
начинает с формы, согласующий (режим чтения) — со схемы. Широкий экран — слева направо,
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
прибытие раньше отъезда из предыдущей точки — ошибка.
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
`TripTasks` для представлений SP). Пустые строки не сохраняются.
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**: форма
ничего не подставляет (решение Тимура — корректно подставлять нечего; про МРП забыть).
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Нужно / Не требуется»,
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути», «+ Ещё ставка
суточных»). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
- Сумма позиции — «Суммой» или «Кол-во × цена» (только перемножение введённого), подписи
над полями. Пробовали таблицу «в строку» и смету «по точкам маршрута» — Тимур вернул
карточки (10-05): «самые понятные».
- Итог на лету: полоса долей по видам статей (палитра проверена dataviz-валидатором) и,
если объектов несколько, **итог по объектам** (`projectShares`; участок — к объекту
пункта назначения, обратный — к объекту, откуда уезжают; страховка/виза — «без объекта»).
- Смета сама не обязательна, но у включённой статьи каждая позиция — с суммой и
привязкой, у «Другого расхода» — название.
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
можно править (см. shared/submitDate.ts).
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
Черновику нужен только сотрудник.
- **Печать** — данные заявки и маршрут согласования/исполнения, **без шапки с адресатом**:
бумажный бланк не повторяется (решение Тимура).
- **Заголовок элемента** (`Title`): `Заявка на командировку — {ФИО} — {города}, {дата начала}`.
- Новые колонки — с префиксом `Trip*`: `StartDate` в других формах уже занят с другим
заголовком (см. грабли коллизий internalName).
## Секции формы
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
ObligationSection (текст обязательства + согласие).
## Точки входа
- rptype: `hr-business-trip`
- Режим согласующего (просмотр): есть (`BusinessTripView`)
- «Создать на основе»: нет
## Не сделано
- Авансовый отчёт после командировки — отдельной формой, пока не придумано как.
@@ -0,0 +1,17 @@
{
"dataListTitle": "Заявка на командировку — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на командировку — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,57 @@
@import '../../../../../styles/design-tokens';
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid $gray-300;
border-radius: $r-full;
background: $white;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .18s;
&:hover {
border-color: $gold-400;
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 2px;
}
}
.icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
// Режим чтения: плашка-метка, не кнопка.
.static {
padding: 3px 10px;
font-size: $fs-label;
cursor: default;
}
.selected {
border-color: $navy-800;
background: $navy-800;
color: $white;
&:hover {
border-color: $navy-700;
background: $navy-700;
color: $white;
}
}
@@ -0,0 +1,50 @@
import * as React from 'react';
import styles from './TransportChips.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { TRANSPORT_TYPES } from '../model';
import { TransportIcon } from './TransportIcon';
interface Props {
value: string;
onChange: (value: string) => void;
/** Подпись группы для экранных дикторов, напр. «Вид транспорта: Алматы → Атырау». */
ariaLabel: string;
}
/**
* Вид транспорта плашками: выбор в один клик и все варианты сразу на виду
* (вместо выпадающего списка). В режиме чтения — только выбранная плашка.
*/
export const TransportChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => {
const readOnly = useFormReadOnly();
if (readOnly) {
return (
<span className={`${styles.chip} ${styles.selected} ${styles.static}`}>
<TransportIcon type={value} className={styles.icon} />
{value || 'не указан'}
</span>
);
}
return (
<div className={styles.chips} role="radiogroup" aria-label={ariaLabel}>
{TRANSPORT_TYPES.map((type) => {
const selected = type === value;
return (
<button
key={type}
type="button"
role="radio"
aria-checked={selected}
className={selected ? `${styles.chip} ${styles.selected}` : styles.chip}
onClick={() => onChange(type)}
>
<TransportIcon type={type} className={styles.icon} />
{type}
</button>
);
})}
</div>
);
};
@@ -0,0 +1,41 @@
import * as React from 'react';
interface Props {
/** Значение из TRANSPORT_TYPES. Неизвестное — иконки нет. */
type: string;
className?: string;
}
/**
* Пути иконок транспорта (Material Icons, лицензия Apache 2.0, viewBox 0 0 24 24).
* В ui-kit таких иконок нет, а для схемы маршрута и плашек выбора они — главный
* способ узнать вид транспорта с первого взгляда.
*/
const PLANE =
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z';
const TRAIN =
'M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z';
const BUS =
'M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z';
const CAR =
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
/** Вид транспорта → иконка. Ключи — значения TRANSPORT_TYPES из model.ts. */
const PATH_BY_TYPE: Record<string, string> = {
Авиа: PLANE,
Железнодорожный: TRAIN,
Автобус: BUS,
'Служебный автотранспорт': CAR,
'Личный автотранспорт': CAR
};
/** Иконка вида транспорта; цвет — currentColor, размер задаёт className. */
export const TransportIcon: React.FC<Props> = ({ type, className }) => {
const path = PATH_BY_TYPE[type];
if (!path) return null;
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden focusable="false">
<path d={path} fill="currentColor" />
</svg>
);
};
@@ -0,0 +1,135 @@
@import '../../../../../../styles/design-tokens';
// С этой ширины «кол-во × цена = итог» помещается в одну строку.
$calc-inline-breakpoint: 560px;
.wrap {
display: flex;
flex-direction: column;
gap: $sp-3;
}
// Сегментный переключатель способа ввода.
.modes {
display: inline-flex;
align-self: flex-start;
padding: 3px;
border-radius: $r-sm;
background: $gray-100;
}
.mode {
padding: 5px 12px;
border: 0;
border-radius: $r-sm - 2px; // вписан в скругление подложки
background: transparent;
color: $gray-500;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 500;
cursor: pointer;
transition: .18s;
&:hover {
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 1px;
}
}
.modeActive {
background: $white;
color: $navy-900;
box-shadow: $sh-1;
}
// Числовое поле: цифры справа, крупно, моноширинные цифры — разряды встают ровно.
.number {
position: relative;
input {
text-align: right;
font-size: $fs-body;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
// Деньги — знак ₸ внутри поля справа (у количества знака нет).
.money {
input {
padding-right: 30px;
}
&::after {
content: '₸';
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
color: $gray-400;
font-family: $font-sans;
font-size: $fs-sm;
pointer-events: none;
}
}
.totalRow {
max-width: 260px;
}
// Телефон: кол-во, цена и итог друг под другом; знаки операций прячем.
.calcRow {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: $sp-3;
}
.sign {
display: none;
}
.result {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: $sp-2;
}
.resultLabel {
font-family: $font-sans;
font-size: $fs-label;
color: $gray-500;
}
.resultValue {
font-family: $font-sans;
font-size: $fs-body;
font-weight: 700;
color: $navy-900;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@media (min-width: $calc-inline-breakpoint) {
.calcRow {
grid-template-columns: minmax(0, 0.7fr) auto minmax(0, 1fr) auto minmax(120px, auto);
align-items: end;
}
.sign {
display: block;
padding-bottom: 10px;
color: $gray-400;
font-size: $fs-body;
}
.result {
flex-direction: column;
align-items: flex-end;
padding-bottom: 8px;
}
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import styles from './AmountFields.module.scss';
import { FormField, MoneyInput } from '../../../../shared/ui';
import { AmountMode, ESTIMATE_KINDS, EstimateKind, IEstimateItem } from '../../model';
import { formatAmount, itemAmount } from '../../estimate';
import { fieldKey } from '../../validation';
interface Props {
kind: EstimateKind;
item: IEstimateItem;
onChange: (patch: Partial<IEstimateItem>) => void;
}
const MODES: ReadonlyArray<{ mode: AmountMode; label: string }> = [
{ mode: 'total', label: 'Суммой' },
{ mode: 'calc', label: 'Кол-во × цена' }
];
interface INumberProps {
id: string;
value: string;
onChange: (value: string) => void;
/** true — деньги: знак ₸ внутри поля справа. Количество — без знака. */
money?: boolean;
}
/** Числовое поле: цифры справа, крупно, с разрядами (MoneyInput хранит «сырые» цифры). */
const NumberField: React.FC<INumberProps> = ({ id, value, onChange, money }) => (
<div className={money ? `${styles.number} ${styles.money}` : styles.number}>
<MoneyInput id={id} value={value} onChange={onChange} />
</div>
);
/**
* Ввод суммы позиции: сразу итогом или количеством × ценой. Форма ничего не
* подставляет — только перемножает введённое. Значения обоих способов хранятся
* независимо: переключился туда-обратно — ничего не потерял.
*/
export const AmountFields: React.FC<Props> = ({ kind, item, onChange }) => {
const info = ESTIMATE_KINDS[kind];
return (
<div className={styles.wrap}>
<div className={styles.modes} role="radiogroup" aria-label="Способ ввода суммы">
{MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={item.mode === mode}
className={item.mode === mode ? `${styles.mode} ${styles.modeActive}` : styles.mode}
onClick={() => onChange({ mode })}
>
{label}
</button>
))}
</div>
{item.mode === 'total' ? (
<div className={styles.totalRow}>
<FormField label="Сумма" htmlFor={fieldKey.itemAmount(item)}>
<NumberField id={fieldKey.itemAmount(item)} value={item.amount} onChange={(v) => onChange({ amount: v })} money />
</FormField>
</div>
) : (
<div className={styles.calcRow}>
<FormField label={info.quantityLabel} htmlFor={fieldKey.itemQuantity(item)}>
<NumberField id={fieldKey.itemQuantity(item)} value={item.quantity} onChange={(v) => onChange({ quantity: v })} />
</FormField>
<span className={styles.sign} aria-hidden>
×
</span>
<FormField label={info.priceLabel} htmlFor={fieldKey.itemPrice(item)}>
<NumberField id={fieldKey.itemPrice(item)} value={item.price} onChange={(v) => onChange({ price: v })} money />
</FormField>
<span className={styles.sign} aria-hidden>
=
</span>
<div className={styles.result}>
<span className={styles.resultLabel}>Итого</span>
<span className={styles.resultValue}>{formatAmount(itemAmount(item))} ₸</span>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,33 @@
@import '../../../../../../styles/design-tokens';
.bar {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.add {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px dashed $gray-300;
border-radius: $r-md;
background: transparent;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .18s;
&:hover {
border-color: $gold-400;
background: $gold-50;
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 2px;
}
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import styles from './EstimateAddBar.module.scss';
import { Icon } from '../../../../shared/ui';
import { ESTIMATE_KINDS, EstimateKind, IEstimateArticle, SINGLE_OPTIONAL_KINDS } from '../../model';
interface Props {
articles: IEstimateArticle[];
onAdd: (kind: EstimateKind) => void;
}
/**
* Кнопки «+ статья». Страховка и виза добавляются один раз (кнопка исчезает,
* пока статья в смете; несколько полисов — позиции внутри статьи), «Другой
* расход» — сколько угодно.
*/
export const EstimateAddBar: React.FC<Props> = ({ articles, onAdd }) => {
const available = SINGLE_OPTIONAL_KINDS.filter((kind) => !articles.some((a) => a.kind === kind));
const kinds: EstimateKind[] = [...available, 'other'];
return (
<div className={styles.bar}>
{kinds.map((kind) => (
<button key={kind} type="button" className={styles.add} onClick={() => onAdd(kind)}>
<Icon name="plus" size="sm" />
{ESTIMATE_KINDS[kind].label}
</button>
))}
</div>
);
};
@@ -0,0 +1,122 @@
@import '../../../../../../styles/design-tokens';
.card {
display: flex;
flex-direction: column;
gap: $sp-4;
padding: $sp-4;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $white;
transition: border-color .18s, background .18s;
&:focus-within {
border-color: $gold-400;
}
}
// Выключенная статья — свёрнутая серая строка «не требуется».
.off {
gap: 0;
padding-block: $sp-3;
border-style: dashed;
background: $gray-50;
.title {
color: $gray-400;
}
}
.head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $sp-3;
}
.title {
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
// Название «Другого расхода» — поле на месте заголовка, тянется на свободное место.
.titleInput {
flex: 1 1 220px;
min-width: 0;
}
.headRight {
display: flex;
align-items: center;
gap: $sp-3;
margin-left: auto;
}
.amount {
font-family: $font-sans;
font-size: $fs-body;
font-weight: 700;
color: $navy-900;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
// Позиции разделены тонкой линией — несколько гостиниц читаются как список.
.items {
display: flex;
flex-direction: column;
> * + * {
margin-top: $sp-4;
padding-top: $sp-4;
border-top: 1px solid $gray-100;
}
}
.addItem {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 6px;
padding: 6px 10px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 500;
cursor: pointer;
transition: .18s;
&:hover {
background: $navy-50;
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 2px;
}
}
@@ -0,0 +1,88 @@
import * as React from 'react';
import styles from './EstimateArticleCard.module.scss';
import { FormField, Icon, TextInput, Toggle } from '../../../../shared/ui';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, ITripRoute } from '../../model';
import { addItem, articleAmount, articleTitle, formatAmount } from '../../estimate';
import { fieldKey } from '../../validation';
import { EstimateItemRow } from './EstimateItemRow';
interface Props {
article: IEstimateArticle;
route: ITripRoute;
onChange: (article: IEstimateArticle) => void;
/** Только у добавленных статей: основные не удаляются, а выключаются переключателем. */
onRemove: () => void;
}
/**
* Статья сметы карточкой с позициями внутри: несколько гостиниц, участков пути,
* ставок суточных. Основная статья включается переключателем «нужно» —
* выключенная сворачивается в серую строку «не требуется». Добавленная
* (страховка, виза, другой расход) всегда включена и удаляется корзиной.
*/
export const EstimateArticleCard: React.FC<Props> = ({ article, route, onChange, onRemove }) => {
const info = ESTIMATE_KINDS[article.kind];
const amount = articleAmount(article);
const cardClass = article.enabled ? styles.card : `${styles.card} ${styles.off}`;
const updateItem = (id: string, patch: Partial<IEstimateItem>): void =>
onChange({ ...article, items: article.items.map((item) => (item.id === id ? { ...item, ...patch } : item)) });
const removeItem = (id: string): void => onChange({ ...article, items: article.items.filter((item) => item.id !== id) });
return (
<div className={cardClass}>
<div className={styles.head}>
{article.kind === 'other' ? (
<div className={styles.titleInput}>
<FormField label="Название расхода" htmlFor={fieldKey.articleTitle(article)}>
<TextInput
id={fieldKey.articleTitle(article)}
value={article.title}
onChange={(v) => onChange({ ...article, title: v })}
placeholder="Напр.: пропуск на объект"
/>
</FormField>
</div>
) : (
<span className={styles.title}>{articleTitle(article)}</span>
)}
<div className={styles.headRight}>
{amount > 0 && <span className={styles.amount}>{formatAmount(amount)} ₸</span>}
{info.core ? (
<Toggle
checked={article.enabled}
onChange={(enabled) => onChange({ ...article, enabled })}
onLabel={article.enabled ? 'Нужно' : 'Не требуется'}
/>
) : (
<button type="button" className={styles.remove} title="Убрать статью" onClick={onRemove}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
</div>
{article.enabled && (
<>
<div className={styles.items}>
{article.items.map((item) => (
<EstimateItemRow
key={item.id}
kind={article.kind}
item={item}
route={route}
onChange={(patch) => updateItem(item.id, patch)}
onRemove={article.items.length > 1 ? () => removeItem(item.id) : undefined}
/>
))}
</div>
<button type="button" className={styles.addItem} onClick={() => onChange(addItem(article, route))}>
<Icon name="plus" size="sm" />
{info.addItemLabel}
</button>
</>
)}
</div>
);
};
@@ -0,0 +1,7 @@
@import '../../../../../../styles/design-tokens';
.editor {
display: flex;
flex-direction: column;
gap: $sp-3;
}
@@ -0,0 +1,43 @@
import * as React from 'react';
import styles from './EstimateEditor.module.scss';
import { ESTIMATE_KIND_ORDER, EstimateKind, IEstimateArticle, ISectionProps } from '../../model';
import { createArticle } from '../../estimate';
import { EstimateArticleCard } from './EstimateArticleCard';
import { EstimateAddBar } from './EstimateAddBar';
import { EstimateSummary } from './EstimateSummary';
type Props = Pick<ISectionProps, 'data' | 'set'>;
/** Новая статья встаёт на своё место по ESTIMATE_KIND_ORDER (страховка — перед визой и т.д.). */
function insertByKind(articles: IEstimateArticle[], article: IEstimateArticle): IEstimateArticle[] {
const rank = (kind: EstimateKind): number => ESTIMATE_KIND_ORDER.indexOf(kind);
const index = articles.findIndex((a) => rank(a.kind) > rank(article.kind));
return index === -1 ? [...articles, article] : [...articles.slice(0, index), article, ...articles.slice(index)];
}
/** Редактор сметы: карточки статей с позициями, кнопки «+ статья» и итог. */
export const EstimateEditor: React.FC<Props> = ({ data, set }) => {
const articles = data.Estimate;
const route = data.Route;
const updateArticle = (updated: IEstimateArticle): void =>
set('Estimate', articles.map((a) => (a.id === updated.id ? updated : a)));
const removeArticle = (id: string): void => set('Estimate', articles.filter((a) => a.id !== id));
const addArticle = (kind: EstimateKind): void => set('Estimate', insertByKind(articles, createArticle(kind, route)));
return (
<div className={styles.editor}>
{articles.map((article) => (
<EstimateArticleCard
key={article.id}
article={article}
route={route}
onChange={updateArticle}
onRemove={() => removeArticle(article.id)}
/>
))}
<EstimateAddBar articles={articles} onAdd={addArticle} />
<EstimateSummary articles={articles} route={route} />
</div>
);
};
@@ -0,0 +1,38 @@
@import '../../../../../../styles/design-tokens';
.row {
display: flex;
flex-direction: column;
gap: $sp-3;
}
.top {
display: flex;
align-items: flex-end;
gap: $sp-3;
}
.link {
flex: 1 1 auto;
max-width: 420px;
min-width: 0;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
@@ -0,0 +1,63 @@
import * as React from 'react';
import styles from './EstimateItemRow.module.scss';
import { FormField, Icon, Select, TextInput } from '../../../../shared/ui';
import type { ISelectOption } from '../../../../shared/ui';
import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model';
import { linkOptions, resolveLinkId } from '../../estimate';
import { fieldKey } from '../../validation';
import { AmountFields } from './AmountFields';
interface Props {
kind: EstimateKind;
item: IEstimateItem;
route: ITripRoute;
onChange: (patch: Partial<IEstimateItem>) => void;
/** Не передан — позицию удалить нельзя (она в статье одна). */
onRemove?: () => void;
}
/**
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
* больше одного — в простой поездке в одну точку выбирать нечего.
*/
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
const info = ESTIMATE_KINDS[kind];
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
const showLink = options.length > 1;
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
return (
<div className={styles.row}>
{(showLink || onRemove) && (
<div className={styles.top}>
{showLink && (
<div className={styles.link}>
<FormField label={linkLabel} htmlFor={fieldKey.itemLink(item)}>
<Select
id={fieldKey.itemLink(item)}
value={resolveLinkId(kind, item.linkId, route)}
onChange={(v) => onChange({ linkId: v })}
options={options}
placeholder="Выберите…"
/>
</FormField>
</div>
)}
{onRemove && (
<button type="button" className={styles.remove} title="Убрать позицию" onClick={onRemove}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
)}
<AmountFields kind={kind} item={item} onChange={onChange} />
<TextInput
value={item.comment}
onChange={(v) => onChange({ comment: v })}
placeholder={info.commentPlaceholder}
ariaLabel={`${info.label}: пояснение`}
/>
</div>
);
};
@@ -0,0 +1,150 @@
@import '../../../../../../styles/design-tokens';
// Категориальная палитра долей — фиксированный порядок по виду статьи
// (ESTIMATE_KIND_ORDER). Проверена валидатором dataviz: CVD ΔE ≥ 9.1, normal ΔE ≥ 19.6
// между соседями. У трёх цветов контраст с фоном < 3:1 — поэтому легенда с
// подписями и суммами видна всегда.
$share-per-diem: #2a78d6;
$share-travel: #eb6834;
$share-accommodation: #1baf7a;
$share-insurance: #eda100;
$share-visa: #e87ba4;
$share-other: #008300;
.summary {
display: flex;
flex-direction: column;
gap: $sp-3;
padding: $sp-4;
border-radius: $r-md;
background: $navy-50;
}
.totalRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-3;
}
.totalLabel {
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
.totalValue {
font-family: $font-sans;
font-size: $fs-form-title;
font-weight: 700;
color: $navy-900;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.hint {
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
}
// Полоса: сегменты через 2px-зазор цвета фона, крайние концы скруглены.
.bar {
display: flex;
gap: 2px;
height: 10px;
overflow: hidden;
border-radius: 4px;
}
.segment {
min-width: 4px; // крошечная доля всё равно видна
height: 100%;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: $sp-2 $sp-5;
margin: 0;
padding: 0;
list-style: none;
li {
display: inline-flex;
align-items: center;
gap: 6px;
}
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.legendLabel {
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-700;
}
.legendValue {
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
font-variant-numeric: tabular-nums;
}
// Итог по объектам — простой список «объект — сумма» под полосой.
.projects {
display: flex;
flex-direction: column;
gap: $sp-1;
padding-top: $sp-3;
border-top: 1px solid $navy-100;
}
.projectsTitle {
margin-bottom: $sp-1;
font-family: $font-sans;
font-size: $fs-caption;
font-weight: 600;
letter-spacing: .03em;
text-transform: uppercase;
color: $gray-500;
}
.projectRow {
display: flex;
justify-content: space-between;
gap: $sp-3;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-700;
}
.projectValue {
font-weight: 600;
color: $navy-900;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.perDiem { background: $share-per-diem; }
.travel { background: $share-travel; }
.accommodation { background: $share-accommodation; }
.insurance { background: $share-insurance; }
.visa { background: $share-visa; }
.other { background: $share-other; }
// Цвета фона по умолчанию браузер на печати выкидывает — оставляем полосу и точки.
@media print {
.segment,
.dot {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,73 @@
import * as React from 'react';
import styles from './EstimateSummary.module.scss';
import { IEstimateArticle, ITripRoute } from '../../model';
import { NO_PROJECT_LABEL, estimateShares, estimateTotal, formatAmount, projectShares } from '../../estimate';
interface Props {
articles: IEstimateArticle[];
route: ITripRoute;
}
const PERCENT = 100;
/**
* Итог сметы: полоса долей по видам статей (согласующий за секунду видит, куда
* уходят деньги) и — если объектов несколько — итог по объектам для разноски
* затрат по проектам. Цвет закреплён за ВИДОМ статьи (styles[kind]), а не за
* местом в полосе. Легенда с суммами и процентами видна всегда: цвет — не
* единственный носитель смысла.
*/
export const EstimateSummary: React.FC<Props> = ({ articles, route }) => {
const total = estimateTotal(articles);
const shares = estimateShares(articles);
const byProject = projectShares(articles, route);
return (
<div className={styles.summary}>
<div className={styles.totalRow}>
<span className={styles.totalLabel}>Итого</span>
<span className={styles.totalValue}>{formatAmount(total)} ₸</span>
</div>
{shares.length === 0 ? (
<div className={styles.hint}>Включите нужные статьи и укажите суммы — итог посчитается сам.</div>
) : (
<>
<div className={styles.bar} role="img" aria-label="Доли статей в итоге">
{shares.map((s) => (
<span
key={s.kind}
className={`${styles.segment} ${styles[s.kind]}`}
style={{ flexGrow: s.share }}
title={`${s.label}: ${formatAmount(s.amount)} ₸ (${Math.round(s.share * PERCENT)}%)`}
/>
))}
</div>
<ul className={styles.legend}>
{shares.map((s) => (
<li key={s.kind}>
<span className={`${styles.dot} ${styles[s.kind]}`} aria-hidden />
<span className={styles.legendLabel}>{s.label}</span>
<span className={styles.legendValue}>
{formatAmount(s.amount)} ₸ · {Math.round(s.share * PERCENT)}%
</span>
</li>
))}
</ul>
</>
)}
{byProject.length > 1 && (
<div className={styles.projects}>
<div className={styles.projectsTitle}>По объектам</div>
{byProject.map((p) => (
<div key={p.project || 'none'} className={styles.projectRow}>
<span>{p.project || NO_PROJECT_LABEL}</span>
<span className={styles.projectValue}>{formatAmount(p.amount)} ₸</span>
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,72 @@
@import '../../../../../../styles/design-tokens';
// Таблица узкая (3 колонки) — на телефоне помещается, прокрутка только на крайний случай.
.wrap {
width: 100%;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid $gray-200;
text-align: left;
vertical-align: top;
}
th {
font-size: $fs-caption;
font-weight: 600;
letter-spacing: .03em;
text-transform: uppercase;
color: $gray-500;
}
}
// Строка статьи — заголовок группы позиций с суммой по статье.
.article td {
padding-top: 12px;
background: $navy-50;
font-weight: 600;
color: $navy-900;
}
// Позиция — с отступом под своей статьёй.
.item {
padding-left: $sp-6 !important; // перебивает общий padding ячеек таблицы
}
.details {
margin-top: 2px;
font-size: $fs-caption;
color: $gray-500;
}
.calc {
color: $gray-700;
white-space: nowrap;
}
.num {
text-align: right !important; // перебивает text-align: left у th/td таблицы
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.off td {
color: $gray-400;
font-style: italic;
}
@media print {
.wrap {
overflow: visible;
}
}
@@ -0,0 +1,59 @@
import * as React from 'react';
import styles from './EstimateTable.module.scss';
import { IEstimateArticle, ITripRoute } from '../../model';
import { articleAmount, articleTitle, formatAmount, itemAmount, itemCalculation, itemLinkLabel } from '../../estimate';
interface Props {
articles: IEstimateArticle[];
route: ITripRoute;
}
/**
* Смета для согласующего и печати — таблица: строка статьи с суммой по ней, под
* ней позиции (где / расчёт / сумма). Итог — в EstimateSummary под таблицей.
* Выключенные основные статьи — строкой «не требуется»: бухгалтерия видит, что
* их не забыли.
*/
export const EstimateTable: React.FC<Props> = ({ articles, route }) => (
<div className={styles.wrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Статья / позиция</th>
<th>Расчёт</th>
<th className={styles.num}>Сумма, ₸</th>
</tr>
</thead>
<tbody>
{articles.map((article) => {
if (!article.enabled) {
return (
<tr key={article.id} className={styles.off}>
<td>{articleTitle(article)}</td>
<td colSpan={2}>не требуется</td>
</tr>
);
}
return (
<React.Fragment key={article.id}>
<tr className={styles.article}>
<td colSpan={2}>{articleTitle(article)}</td>
<td className={styles.num}>{formatAmount(articleAmount(article))}</td>
</tr>
{article.items.map((item) => (
<tr key={item.id}>
<td className={styles.item}>
<div>{itemLinkLabel(article.kind, item, route) || '—'}</div>
{item.comment.trim() && <div className={styles.details}>{item.comment.trim()}</div>}
</td>
<td className={styles.calc}>{itemCalculation(item) || '—'}</td>
<td className={styles.num}>{formatAmount(itemAmount(item))}</td>
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
);
@@ -0,0 +1,119 @@
@import '../../../../../../styles/design-tokens';
// Ширина колонки маркеров слева — под кружок с номером точки.
$marker-size: 26px;
.timeline {
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: $marker-size minmax(0, 1fr);
gap: $sp-3;
position: relative;
// Сплошная линия маршрута через все строки — по центру колонки маркеров.
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: calc(#{$marker-size} / 2 - 1px);
width: 2px;
background: $navy-100;
}
// Линия начинается с первой точки и заканчивается на последней.
&:first-child::before {
top: $sp-6;
}
&:last-child::before {
bottom: calc(100% - #{$sp-6});
}
}
.marker {
position: relative; // поверх линии
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
align-self: start;
margin-top: $sp-4;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 700;
}
// Город отправления / возвращения — маленький тёмный кружок.
.endpoint {
width: 12px;
height: 12px;
margin-inline: auto;
border: 3px solid $white;
border-radius: 50%;
background: $navy-800;
box-shadow: 0 0 0 1px $navy-800;
}
// Точка маршрута — золотой кружок с номером.
.stop {
width: $marker-size;
height: $marker-size;
border-radius: 50%;
background: $gold-500;
color: $navy-950;
}
// Участок пути — на линии без маркера.
.line {
visibility: hidden;
}
.content {
min-width: 0;
padding-block: $sp-2;
}
.origin {
max-width: 360px;
}
.addStop {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px dashed $gray-300;
border-radius: $r-md;
background: transparent;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .18s;
&:hover {
border-color: $gold-400;
background: $gold-50;
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 2px;
}
}
.end {
padding-top: $sp-2;
}
.endText {
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-700;
}
@@ -0,0 +1,127 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteEditor.module.scss';
import { FormField, Icon, TextInput, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, routeLegs } from '../../route';
import { fieldKey } from '../../validation';
import { RouteStopCard } from './RouteStopCard';
import { RouteLegRow } from './RouteLegRow';
interface Props {
route: ITripRoute;
onChange: (route: ITripRoute) => void;
context: WebPartContext;
}
type MarkerKind = 'endpoint' | 'stop' | 'line';
/** Строка линии маршрута: слева маркер (точка/номер/линия), справа содержимое. */
const TimelineRow: React.FC<{ marker: MarkerKind; label?: string; children: React.ReactNode }> = ({
marker,
label,
children
}) => (
<div className={styles.row}>
<div className={`${styles.marker} ${styles[marker]}`} aria-hidden>
{label}
</div>
<div className={styles.content}>{children}</div>
</div>
);
/**
* Маршрут командировки лентой: город отправления → точки (город + объект +
* даты) → обратно. Между точками — участки пути с видом транспорта; участки
* не хранятся, а выводятся из точек (route.ts/routeLegs). Простая поездка — одна
* точка; «+ Добавить точку» — для поездок по нескольким объектам.
*/
export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
const readOnly = useFormReadOnly();
const legs = routeLegs(route);
const legTo = (stopId: string): IRouteLeg | undefined => legs.find((l) => l.toId === stopId);
const returnLeg = route.returnToOrigin ? legs[legs.length - 1] : undefined;
const updateStop = (id: string, patch: Partial<IRouteStop>): void =>
onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const addStop = (): void =>
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
const removeStop = (id: string): void => {
// Виды транспорта участков, которые шли через удалённую точку, больше ни к чему не относятся.
const legTransport: Record<string, string> = {};
Object.keys(route.legTransport)
.filter((key) => key.split('>').indexOf(id) === -1)
.forEach((key) => {
legTransport[key] = route.legTransport[key];
});
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
};
const setTransport = (id: string, value: string): void =>
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } });
const legRow = (leg: IRouteLeg | undefined): React.ReactNode =>
leg && (
<TimelineRow marker="line">
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} />
</TimelineRow>
);
return (
<div className={styles.timeline}>
<TimelineRow marker="endpoint">
<div className={styles.origin}>
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
<TextInput
id={fieldKey.origin}
value={route.origin}
onChange={(v) => onChange({ ...route, origin: v })}
placeholder="Напр.: Алматы"
/>
</FormField>
</div>
</TimelineRow>
{route.stops.map((stop, i) => (
<React.Fragment key={stop.id}>
{legRow(legTo(stop.id))}
<TimelineRow marker="stop" label={String(i + 1)}>
<RouteStopCard
stop={stop}
index={i}
context={context}
onChange={(patch) => updateStop(stop.id, patch)}
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
/>
</TimelineRow>
</React.Fragment>
))}
{!readOnly && (
<TimelineRow marker="line">
<button type="button" className={styles.addStop} onClick={addStop}>
<Icon name="plus" size="sm" />
Добавить точку маршрута
</button>
</TimelineRow>
)}
{legRow(returnLeg)}
<TimelineRow marker="endpoint">
<div className={styles.end}>
{readOnly ? (
<span className={styles.endText}>
{route.returnToOrigin ? `Возвращение: ${route.origin || 'город отправления'}` : 'Без возвращения в город отправления'}
</span>
) : (
<Toggle
checked={route.returnToOrigin}
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
onLabel={`Возвращение в ${route.origin.trim() || 'город отправления'}`}
/>
)}
</div>
</TimelineRow>
</div>
);
};
@@ -0,0 +1,6 @@
@import '../../../../../../styles/design-tokens';
// Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута.
.leg {
padding-block: $sp-2;
}
@@ -0,0 +1,25 @@
import * as React from 'react';
import styles from './RouteLegRow.module.scss';
import { FormField } from '../../../../shared/ui';
import { IRouteLeg, legLabel } from '../../route';
import { fieldKey } from '../../validation';
import { TransportChips } from '../TransportChips';
interface Props {
leg: IRouteLeg;
transport: string;
onTransportChange: (value: string) => void;
}
/** Участок пути между точками на линии маршрута: «Алматы → Атырау» и вид транспорта. */
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange }) => (
<div className={styles.leg}>
<FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}>
<TransportChips
value={transport}
onChange={onTransportChange}
ariaLabel={`Вид транспорта: ${legLabel(leg)}`}
/>
</FormField>
</div>
);
@@ -0,0 +1,298 @@
@import '../../../../../../styles/design-tokens';
// Общие стили схемы маршрута: RouteScheme, SchemeTrack, SchemeNodeLabel, TransportBadge.
// С этой ширины ветка идёт слева направо; уже — сверху вниз (горизонтальная не влезет).
$scheme-horizontal-breakpoint: 640px;
// Кружок точки на вертикальной ветке (на горизонтальной размеры — в SchemeTrack.tsx).
$marker-size: 28px;
// Линия рисуется фоном — отдельный пустой элемент-линия на живой странице не отрисовывался.
$route-line: linear-gradient($navy-700, $navy-700) no-repeat;
.scheme {
display: flex;
flex-direction: column;
gap: $sp-4;
padding: $sp-5 $sp-4;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $navy-50;
}
.stats {
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 600;
color: $navy-800;
}
// ---- Подпись узла ----------------------------------------------------------
.cell {
display: block;
min-width: 0;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-sm;
background: transparent;
font: inherit;
color: inherit;
text-align: inherit;
}
.clickable {
cursor: pointer;
transition: background .18s;
&:hover {
background: $white;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 2px;
}
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.city {
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
// Объект — не больше двух строк: в справочнике бывают названия с полным адресом.
.project {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 500;
line-height: 1.35;
color: $gray-700;
}
.meta {
font-family: $font-sans;
font-size: $fs-label;
color: $gray-500;
font-variant-numeric: tabular-nums;
}
.amount {
margin-top: 2px;
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 700;
color: $navy-900;
font-variant-numeric: tabular-nums;
}
// ---- Плашка транспорта -----------------------------------------------------
.transport {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border: 1px solid $navy-100;
border-radius: $r-full;
background: $white;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 500;
color: $navy-800;
white-space: nowrap;
}
.transportIcon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
// Транспорт не выбран — подсказка, что схема ещё не полная.
.missing {
border-style: dashed;
color: $gray-400;
}
// ---- Горизонтальная ветка (SVG) --------------------------------------------
.trackWrap {
display: none;
width: 100%;
overflow-x: auto;
}
.hTrack {
display: flex;
flex-direction: column;
gap: $sp-2;
}
// Полоса с SVG-веткой; плашки транспорта — поверх неё по серединам участков.
.hLine {
position: relative;
}
.hSvg {
display: block;
overflow: visible;
}
.svgLine {
stroke: $navy-700;
stroke-width: 2;
}
.arrow {
fill: $navy-700;
}
.svgStop {
fill: $gold-500;
}
.svgNumber {
fill: $navy-950;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 700;
}
.svgEndpointRing {
fill: $navy-50;
stroke: $navy-800;
stroke-width: 1.5;
}
.svgEndpointDot {
fill: $navy-800;
}
.hBadge {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
}
.hLabels {
display: grid;
text-align: center;
.text {
align-items: center;
}
}
// ---- Вертикальная ветка (телефон) ------------------------------------------
.vTrack {
display: flex;
flex-direction: column;
}
.vRow {
display: grid;
grid-template-columns: $marker-size minmax(0, 1fr);
gap: $sp-2;
min-height: 40px;
}
// Колонка маркеров: линия — фоном по центру; у первого узла линия начинается от
// центра кружка, у последнего — заканчивается на нём.
.vRail {
display: flex;
justify-content: center;
align-items: flex-start;
background: $route-line;
background-size: 2px 100%;
background-position: center 0;
}
.vFirst {
background-size: 2px calc(100% - #{$marker-size} / 2);
background-position: center bottom;
}
.vLast {
background-size: 2px calc(#{$marker-size} / 2);
background-position: center top;
}
// Единственный узел — и первый, и последний: линии нет.
.vFirst.vLast {
background: none;
}
.vStop {
display: flex;
align-items: center;
justify-content: center;
width: $marker-size;
height: $marker-size;
border-radius: 50%;
background: $gold-500;
color: $navy-950;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 700;
}
.vEndpoint {
width: 16px;
height: 16px;
margin-top: 6px; // центр на высоте центра кружка точки
border: 4px solid $navy-50;
border-radius: 50%;
background: $navy-800;
box-shadow: 0 0 0 1.5px $navy-800;
}
.vLeg {
display: flex;
align-items: center;
padding-block: $sp-2;
}
// ---- Переключение раскладок -------------------------------------------------
@media (min-width: $scheme-horizontal-breakpoint) {
.trackWrap {
display: block;
}
.vTrack {
display: none;
}
}
// Печать: всегда горизонтальная ветка, без прокрутки, цвета сохраняются.
@media print {
.trackWrap {
display: block;
overflow: visible;
}
.vTrack {
display: none;
}
.scheme,
.vRail,
.vStop,
.vEndpoint {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,63 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { IEstimateArticle, ITripRoute } from '../../model';
import { routeLegs, routePeriod, routeProjects } from '../../route';
import { estimateTotal, formatAmount, stopTotals } from '../../estimate';
import { plural, tripDays } from '../../tripData';
import { ISchemeNode } from './schemeNode';
import { shortDate } from './SchemeNodeLabel';
import { SchemeTrack } from './SchemeTrack';
interface Props {
route: ITripRoute;
articles: IEstimateArticle[];
/** Клик по точке — открыть её в форме. Не передан — точки не кликабельны. */
onSelectStop?: (stopId: string) => void;
}
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
const period = routePeriod(route);
const origin = route.origin.trim();
const withDate = (caption: string, iso: string): string => (iso ? `${caption} ${shortDate(iso)}` : caption);
const nodes: ISchemeNode[] = [
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
];
route.stops.forEach((stop, i) =>
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stop.city.trim(), amount: amounts.get(stop.id) || 0 })
);
if (route.returnToOrigin) {
nodes.push({ key: 'return', kind: 'endpoint', city: origin, caption: withDate('возвращение', period.end), amount: 0 });
}
return nodes;
}
/**
* Маршрут картинкой, как схема метро: город отправления → точки → обратно, на
* линиях — вид транспорта, под точками — объект, даты, ночи и расходы по точке
* из сметы. Только для просмотра: данные те же, что в форме маршрута.
* Геометрию (кружки, линии, стрелки) рисует SchemeTrack по координатам.
*/
export const RouteScheme: React.FC<Props> = ({ route, articles, onSelectStop }) => {
const legs = routeLegs(route);
const nodes = schemeNodes(route, stopTotals(articles, route));
const period = routePeriod(route);
const days = tripDays(period.start, period.end);
const projects = routeProjects(route).length;
const total = estimateTotal(articles);
const stats = [
days ? plural(days, ['день', 'дня', 'дней']) : '',
projects ? plural(projects, ['объект', 'объекта', 'объектов']) : '',
plural(legs.length, ['переезд', 'переезда', 'переездов']),
total > 0 ? `${formatAmount(total)} ₸` : ''
].filter(Boolean);
return (
<div className={styles.scheme}>
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} onSelectStop={onSelectStop} />
<div className={styles.stats}>{stats.join(' · ')}</div>
</div>
);
};
@@ -0,0 +1,73 @@
@import '../../../../../../styles/design-tokens';
// С этой ширины поля точки встают в две колонки.
$stop-two-columns-breakpoint: 560px;
.card {
display: flex;
flex-direction: column;
gap: $sp-3;
padding: $sp-4;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $white;
transition: border-color .18s;
&:focus-within {
border-color: $gold-400;
}
}
.head {
display: flex;
align-items: center;
gap: $sp-3;
}
.title {
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
.nights {
padding: 2px 10px;
border-radius: $r-full;
background: $navy-100;
color: $navy-800;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 500;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: $sp-3 $sp-4;
}
@media (min-width: $stop-two-columns-breakpoint) {
.grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
@@ -0,0 +1,75 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteStopCard.module.scss';
import { FormField, Icon, TextInput } from '../../../../shared/ui';
import { ProjectPicker } from '../../../../shared/dictionary';
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { stopNights } from '../../route';
import { plural } from '../../tripData';
import { fieldKey } from '../../validation';
interface Props {
stop: IRouteStop;
index: number;
context: WebPartContext;
onChange: (patch: Partial<IRouteStop>) => void;
/** Не передан — точку удалить нельзя (она единственная). */
onRemove?: () => void;
}
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
export function stopCardDomId(stopId: string): string {
return `route-stop-${stopId}`;
}
/** Точка маршрута: город, объект из справочника и даты пребывания. */
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
const nights = stopNights(stop);
return (
<div className={styles.card} id={stopCardDomId(stop.id)}>
<div className={styles.head}>
<span className={styles.title}>Точка {index + 1}</span>
{nights !== undefined && nights > 0 && (
<span className={styles.nights}>{plural(nights, ['ночь', 'ночи', 'ночей'])}</span>
)}
{onRemove && (
<button type="button" className={styles.remove} title="Убрать точку из маршрута" onClick={onRemove}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
<div className={styles.grid}>
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput id={fieldKey.stopCity(stop)} value={stop.city} onChange={(v) => onChange({ city: v })} />
</FormField>
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
<ProjectPicker
id={fieldKey.stopProject(stop)}
context={context}
value={stop.project}
onChange={(name, id) => onChange({ project: name, projectId: id })}
placeholder={PROJECT_PLACEHOLDER}
/>
</FormField>
<FormField label="Прибытие" htmlFor={fieldKey.stopFrom(stop)} required>
<TextInput
id={fieldKey.stopFrom(stop)}
type="date"
value={stop.dateFrom}
onChange={(v) => onChange({ dateFrom: v })}
/>
</FormField>
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
<TextInput
id={fieldKey.stopTo(stop)}
type="date"
value={stop.dateTo}
onChange={(v) => onChange({ dateTo: v })}
min={stop.dateFrom || undefined}
/>
</FormField>
</div>
</div>
);
};
@@ -0,0 +1,38 @@
@import '../../../../../../styles/design-tokens';
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете.
.switch {
display: inline-flex;
align-self: flex-start;
padding: 3px;
border-radius: $r-sm;
background: $gray-100;
}
.option {
padding: 6px 16px;
border: 0;
border-radius: $r-sm - 2px; // вписан в скругление подложки
background: transparent;
color: $gray-500;
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 500;
cursor: pointer;
transition: .18s;
&:hover {
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 1px;
}
}
.active {
background: $white;
color: $navy-900;
box-shadow: $sh-1;
}
@@ -0,0 +1,32 @@
import * as React from 'react';
import styles from './RouteViewSwitch.module.scss';
export type RouteViewMode = 'form' | 'scheme';
interface Props {
value: RouteViewMode;
onChange: (mode: RouteViewMode) => void;
}
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [
{ mode: 'form', label: 'Форма' },
{ mode: 'scheme', label: 'Схема' }
];
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута">
{MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={value === mode}
className={value === mode ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(mode)}
>
{label}
</button>
))}
</div>
);
@@ -0,0 +1,52 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { IRouteStop } from '../../model';
import { stopNights } from '../../route';
import { formatAmount } from '../../estimate';
import { formatIsoDate } from '../../../../shared/dateUtils';
import { plural } from '../../tripData';
import { ISchemeNode } from './schemeNode';
interface Props {
node: ISchemeNode;
}
const NOT_SET = 'не указан';
/** «05.10» — на схеме год лишний, он один на всю поездку. */
export function shortDate(iso: string): string {
return iso ? formatIsoDate(iso).slice(0, 5) : '';
}
function stopDates(stop: IRouteStop): string {
if (!stop.dateFrom && !stop.dateTo) return '';
return `${shortDate(stop.dateFrom) || '…'}–${shortDate(stop.dateTo) || '…'}`;
}
/** Подписи узла схемы: город, объект, даты, ночи, расходы по точке. */
export const SchemeNodeLabel: React.FC<Props> = ({ node }) => {
if (node.kind === 'endpoint' || !node.stop) {
return (
<span className={styles.text}>
<span className={styles.city}>{node.city || NOT_SET}</span>
{node.caption && <span className={styles.meta}>{node.caption}</span>}
</span>
);
}
const stop = node.stop;
const nights = stopNights(stop);
const dates = stopDates(stop);
return (
<span className={styles.text}>
<span className={styles.city}>{stop.city.trim() || `Город ${NOT_SET}`}</span>
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
<span className={styles.project} title={stop.project.trim() || undefined}>
{stop.project.trim() || 'объект не выбран'}
</span>
{dates && <span className={styles.meta}>{dates}</span>}
{nights !== undefined && nights > 0 && <span className={styles.meta}>{plural(nights, ['ночь', 'ночи', 'ночей'])}</span>}
{node.amount > 0 && <span className={styles.amount}>{formatAmount(node.amount)} ₸</span>}
</span>
);
};
@@ -0,0 +1,172 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { IRouteLeg } from '../../route';
import { ISchemeNode } from './schemeNode';
import { SchemeNodeLabel } from './SchemeNodeLabel';
import { TransportBadge } from './TransportBadge';
interface Props {
nodes: ISchemeNode[];
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
legs: IRouteLeg[];
legTransport: Record<string, string>;
onSelectStop?: (stopId: string) => void;
}
// Геометрия ветки в пикселях SVG (высота полосы = диаметр кружка точки).
const TRACK_HEIGHT = 28;
const CENTER_Y = TRACK_HEIGHT / 2;
const STOP_RADIUS = 14;
const ENDPOINT_RADIUS = 8;
const ENDPOINT_DOT_RADIUS = 4;
// Стрелка: треугольник 10×10. Линия идёт центр-в-центр, поэтому любой зазор
// между остриём и кружком показывает «хвостик» линии. Остриё заходит под кружок
// на 1px (отрицательный зазор) — кружки нарисованы поверх линий, щели нет.
const ARROW_SIZE = 10;
const ARROW_GAP = -1;
// Колонка узла не уже этого — иначе подписи не читаются; длинный маршрут прокручивается вбок.
const MIN_COLUMN_WIDTH = 130;
const ARROW_TO_STOP_ID = 'rp-trip-arrow-stop';
const ARROW_TO_ENDPOINT_ID = 'rp-trip-arrow-endpoint';
/** Центр колонки узла в процентах ширины: узлы стоят ровно посередине своих колонок. */
function columnCenter(index: number, count: number): string {
return `${((index + 0.5) / count) * 100}%`;
}
/**
* Стрелка-маркер SVG. refX сдвигает остриё назад на радиус кружка + зазор —
* так линия идёт центр-в-центр, а остриё всегда стоит ровно у края кружка.
*/
const ArrowMarker: React.FC<{ id: string; targetRadius: number }> = ({ id, targetRadius }) => (
<marker
id={id}
viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}
markerWidth={ARROW_SIZE}
markerHeight={ARROW_SIZE}
markerUnits="userSpaceOnUse"
refX={ARROW_SIZE + targetRadius + ARROW_GAP}
refY={ARROW_SIZE / 2}
orient="auto"
>
<path d={`M0,0 L${ARROW_SIZE},${ARROW_SIZE / 2} L0,${ARROW_SIZE} z`} className={styles.arrow} />
</marker>
);
/** Подпись узла: точка маршрута кликабельна (открыть в форме), город отправления — нет. */
const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) => void }> = ({ node, onSelectStop }) => {
const stopId = node.stop?.id;
if (!onSelectStop || !stopId) {
return (
<div className={styles.cell}>
<SchemeNodeLabel node={node} />
</div>
);
}
return (
<button
type="button"
className={`${styles.cell} ${styles.clickable}`}
onClick={() => onSelectStop(stopId)}
title="Открыть точку в форме"
>
<SchemeNodeLabel node={node} />
</button>
);
};
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => {
const count = nodes.length;
return (
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
<div className={styles.hLine}>
<svg className={styles.hSvg} width="100%" height={TRACK_HEIGHT} aria-hidden focusable="false">
<defs>
<ArrowMarker id={ARROW_TO_STOP_ID} targetRadius={STOP_RADIUS} />
<ArrowMarker id={ARROW_TO_ENDPOINT_ID} targetRadius={ENDPOINT_RADIUS} />
</defs>
{legs.map((leg, i) => (
<line
key={leg.id}
x1={columnCenter(i, count)}
x2={columnCenter(i + 1, count)}
y1={CENTER_Y}
y2={CENTER_Y}
className={styles.svgLine}
markerEnd={`url(#${nodes[i + 1]?.kind === 'stop' ? ARROW_TO_STOP_ID : ARROW_TO_ENDPOINT_ID})`}
/>
))}
{nodes.map((node, i) =>
node.kind === 'stop' ? (
<g key={node.key}>
<circle cx={columnCenter(i, count)} cy={CENTER_Y} r={STOP_RADIUS} className={styles.svgStop} />
<text x={columnCenter(i, count)} y={CENTER_Y} className={styles.svgNumber} textAnchor="middle" dominantBaseline="central">
{node.number}
</text>
</g>
) : (
<g key={node.key}>
<circle cx={columnCenter(i, count)} cy={CENTER_Y} r={ENDPOINT_RADIUS} className={styles.svgEndpointRing} />
<circle cx={columnCenter(i, count)} cy={CENTER_Y} r={ENDPOINT_DOT_RADIUS} className={styles.svgEndpointDot} />
</g>
)
)}
</svg>
{legs.map((leg, i) => (
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
</span>
))}
</div>
<div className={styles.hLabels} style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}>
{nodes.map((node) => (
<NodeCell key={node.key} node={node} onSelectStop={onSelectStop} />
))}
</div>
</div>
);
};
/** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => (
<div className={styles.vTrack}>
{nodes.map((node, i) => {
const leg = legs[i];
const position = [i === 0 ? styles.vFirst : '', i === nodes.length - 1 ? styles.vLast : ''].join(' ');
return (
<React.Fragment key={node.key}>
<div className={styles.vRow}>
<span className={`${styles.vRail} ${position}`}>
{node.kind === 'stop' ? (
<span className={styles.vStop}>{node.number}</span>
) : (
<span className={styles.vEndpoint} />
)}
</span>
<NodeCell node={node} onSelectStop={onSelectStop} />
</div>
{leg && i < nodes.length - 1 && (
<div className={styles.vRow}>
<span className={styles.vRail} />
<span className={styles.vLeg}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
</span>
</div>
)}
</React.Fragment>
);
})}
</div>
);
/** Ветка маршрута в двух раскладках; какая видна — решает ширина экрана (см. стили). */
export const SchemeTrack: React.FC<Props> = (props) => (
<>
<div className={styles.trackWrap}>
<HorizontalTrack {...props} />
</div>
<VerticalTrack {...props} />
</>
);
@@ -0,0 +1,21 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { TRANSPORT_SHORT } from '../../model';
import { IRouteLeg } from '../../route';
import { TransportIcon } from '../TransportIcon';
interface Props {
leg: IRouteLeg;
transport: string;
}
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => (
<span
className={transport ? styles.transport : `${styles.transport} ${styles.missing}`}
title={`${leg.from} → ${leg.to}${transport ? `: ${transport}` : ''}`}
>
<TransportIcon type={transport} className={styles.transportIcon} />
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
</span>
);
@@ -0,0 +1,17 @@
import type { IRouteStop } from '../../model';
/** Узел схемы маршрута: город отправления/возвращения или точка маршрута. */
export interface ISchemeNode {
key: string;
kind: 'endpoint' | 'stop';
/** Номер точки в кружке — только у точки маршрута. */
number?: number;
/** Сама точка — только у точки маршрута. */
stop?: IRouteStop;
/** Город отправления — у endpoint. */
city: string;
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
caption?: string;
/** Расходы по точке из сметы; 0 — не показываем. */
amount: number;
}
@@ -0,0 +1,133 @@
import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute } from './model';
import {
addItem,
articleAmount,
createArticle,
estimateShares,
estimateTotal,
formatEstimateText,
parseEstimate,
projectShares,
resolveLinkId,
serializeEstimate,
stopTotals
} from './estimate';
const ROUTE: ITripRoute = {
origin: 'Алматы',
stops: [
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
],
returnToOrigin: true,
legTransport: {}
};
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
id: 'i',
linkId: '',
mode: 'total',
amount: '',
quantity: '',
price: '',
comment: '',
...patch
});
const hotels: IEstimateArticle = {
id: 'accommodation',
kind: 'accommodation',
title: '',
enabled: true,
items: [
item({ id: 'h1', linkId: 's1', mode: 'calc', quantity: '3', price: '36000', comment: 'Ренессанс' }),
item({ id: 'h2', linkId: 's2', mode: 'calc', quantity: '2', price: '35000' })
]
};
const travel: IEstimateArticle = {
id: 'travel',
kind: 'travel',
title: '',
enabled: true,
items: [item({ id: 't1', linkId: 'origin>s1', amount: '65000' }), item({ id: 't2', linkId: 's2>origin', amount: '68000' })]
};
const perDiemOff: IEstimateArticle = { id: 'perDiem', kind: 'perDiem', title: '', enabled: false, items: [item({ amount: '99999' })] };
const visa: IEstimateArticle = { id: 'visa-x', kind: 'visa', title: '', enabled: true, items: [item({ amount: '20000' })] };
const ALL = [perDiemOff, travel, hotels, visa];
describe('суммы', () => {
it('статья — сумма позиций, выключенная — 0', () => {
expect(articleAmount(hotels)).toBe(178000);
expect(articleAmount(perDiemOff)).toBe(0);
expect(estimateTotal(ALL)).toBe(178000 + 133000 + 20000);
});
it('доли по видам статей — в фиксированном порядке', () => {
expect(estimateShares(ALL).map((s) => s.kind)).toEqual(['travel', 'accommodation', 'visa']);
expect(estimateShares(EMPTY_ESTIMATE)).toEqual([]);
});
it('итог по объектам: обратный участок — к объекту, откуда уезжают; виза — без объекта', () => {
expect(projectShares(ALL, ROUTE)).toEqual([
{ project: 'Нефтебаза-2', amount: 108000 + 65000 },
{ project: 'Порт Курык', amount: 70000 + 68000 },
{ project: '', amount: 20000 }
]);
});
it('расходы по точкам — для подписей на схеме; без привязки не попадают', () => {
const totals = stopTotals(ALL, ROUTE);
expect(totals.get('s1')).toBe(108000 + 65000);
expect(totals.get('s2')).toBe(70000 + 68000);
expect(totals.size).toBe(2);
});
});
describe('привязка позиций', () => {
it('единственный вариант подставляется сам', () => {
const oneStop = { ...ROUTE, stops: [ROUTE.stops[0]] };
expect(resolveLinkId('accommodation', '', oneStop)).toBe('s1');
});
it('удалённая точка — привязку нужно выбрать заново', () => {
expect(resolveLinkId('accommodation', 'gone', ROUTE)).toBe('');
});
it('новая позиция привязывается к первой непокрытой точке/участку', () => {
const withOneHotel = { ...hotels, items: [hotels.items[0]] };
expect(addItem(withOneHotel, ROUTE).items[1].linkId).toBe('s2');
const withOneLeg = { ...travel, items: [travel.items[0]] };
expect(addItem(withOneLeg, ROUTE).items[1].linkId).toBe('s1>s2');
});
it('новая статья без привязки (виза) — с пустой позицией', () => {
const article = createArticle('visa', EMPTY_ROUTE);
expect(article.items).toHaveLength(1);
expect(article.items[0].linkId).toBe('');
});
});
describe('текст для SP и хранение', () => {
it('позиция на строку, с привязкой и расчётом', () => {
// Intl ru-RU разделяет разряды неразрывным пробелом (код 160) — приводим к обычному для сравнения.
const NBSP = String.fromCharCode(160);
const text = formatEstimateText([travel, hotels], ROUTE).split(NBSP).join(' ');
expect(text.split('\n')).toEqual([
'Проезд (Алматы → Атырау): 65 000 ₸',
'Проезд (Актау → Алматы): 68 000 ₸',
'Проживание (Атырау · Нефтебаза-2): 3 × 36 000 ₸ = 108 000 ₸',
'Проживание (Актау · Порт Курык): 2 × 35 000 ₸ = 70 000 ₸'
]);
});
it('сериализация и разбор дают то же самое', () => {
expect(parseEstimate(serializeEstimate(ALL))).toEqual(ALL);
});
it('битый JSON — смета новой заявки; недостающие основные статьи возвращаются', () => {
expect(parseEstimate('{oops')).toEqual(EMPTY_ESTIMATE);
const raw = JSON.stringify({ articles: [travel, { kind: 'unknown' }] });
expect(parseEstimate(raw).map((a) => a.kind)).toEqual(['perDiem', 'travel', 'accommodation']);
});
});
@@ -0,0 +1,297 @@
import {
AmountMode,
EMPTY_ESTIMATE,
ESTIMATE_KINDS,
ESTIMATE_KIND_ORDER,
EstimateKind,
IEstimateArticle,
IEstimateItem,
ITripRoute
} from './model';
import { legLabel, newId, routeLegs, stopLabel } from './route';
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
function toNumber(raw: string): number {
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}
/** Сумма позиции: введённая итогом либо количество × цена — смотря как человек вводил. */
export function itemAmount(item: IEstimateItem): number {
return item.mode === 'calc' ? toNumber(item.quantity) * toNumber(item.price) : toNumber(item.amount);
}
/** Сумма статьи; выключенная статья («не требуется») — 0. */
export function articleAmount(article: IEstimateArticle): number {
if (!article.enabled) return 0;
return article.items.reduce((sum, item) => sum + itemAmount(item), 0);
}
export function estimateTotal(articles: IEstimateArticle[]): number {
return articles.reduce((sum, article) => sum + articleAmount(article), 0);
}
/** Подпись статьи: своё название у «Другого расхода», иначе — из каталога. */
export function articleTitle(article: IEstimateArticle): string {
if (article.kind === 'other') return article.title.trim() || ESTIMATE_KINDS.other.label;
return ESTIMATE_KINDS[article.kind].label;
}
/** Сумма для показа: «1 234 567». Тенге без копеек — MoneyInput принимает только целые. */
export function formatAmount(value: number): string {
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(value);
}
/**
* Расчёт для печати и колонки SP: «3 × 36 000 ₸»; при вводе итогом — ''. Без
* единиц: склонять «ночь/ночи/ночей» ради одной строки не стоит, а что считали —
* понятно из статьи.
*/
export function itemCalculation(item: IEstimateItem): string {
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
}
// ---------------------------------------------------------------------------
// Привязка позиций к маршруту
// ---------------------------------------------------------------------------
export interface ILinkOption {
id: string;
label: string;
}
/** К чему можно привязать позицию статьи: точки маршрута или участки пути. */
export function linkOptions(kind: EstimateKind, route: ITripRoute): ILinkOption[] {
const target = ESTIMATE_KINDS[kind].link;
if (target === 'stop') return route.stops.map((s) => ({ id: s.id, label: stopLabel(s) }));
if (target === 'leg') return routeLegs(route).map((l) => ({ id: l.id, label: legLabel(l) }));
return [];
}
/**
* Фактическая привязка позиции. Вариант один (простая поездка в одну точку) —
* это он, выбирать нечего. Сохранённая привязка исчезла из маршрута (точку
* удалили) — '' : позицию нужно привязать заново, валидация это подсветит.
*/
export function resolveLinkId(kind: EstimateKind, linkId: string, route: ITripRoute): string {
const options = linkOptions(kind, route);
if (options.length === 1) return options[0].id;
return options.some((o) => o.id === linkId) ? linkId : '';
}
/** Привязка новой позиции: первая точка/участок, к которым в статье ещё нет позиции. */
function nextLinkId(article: IEstimateArticle, route: ITripRoute): string {
const options = linkOptions(article.kind, route);
const used = article.items.map((item) => resolveLinkId(article.kind, item.linkId, route));
const free = options.find((o) => used.indexOf(o.id) === -1);
return (free || options[0])?.id || '';
}
export function createItem(kind: EstimateKind, linkId: string): IEstimateItem {
return {
id: newId('item'),
linkId,
mode: ESTIMATE_KINDS[kind].defaultMode,
amount: '',
quantity: '',
price: '',
comment: ''
};
}
/** Новая позиция статьи — сразу привязана к следующей ещё не покрытой точке/участку. */
export function addItem(article: IEstimateArticle, route: ITripRoute): IEstimateArticle {
return { ...article, items: [...article.items, createItem(article.kind, nextLinkId(article, route))] };
}
/** Новая добавляемая статья (страховка, виза, другой расход) с одной пустой позицией. */
export function createArticle(kind: EstimateKind, route: ITripRoute): IEstimateArticle {
const empty: IEstimateArticle = { id: newId(kind), kind, title: '', enabled: true, items: [] };
return addItem(empty, route);
}
// ---------------------------------------------------------------------------
// Разрезы итога
// ---------------------------------------------------------------------------
/** Доля вида статей в итоге — для полосы под сметой. */
export interface IEstimateShare {
kind: EstimateKind;
label: string;
amount: number;
/** 0…1 от итога. */
share: number;
}
/**
* Разбивка итога по видам статей в фиксированном порядке ESTIMATE_KIND_ORDER.
* Все «другие расходы» — одна доля: цвет полосы закреплён за видом статьи.
*/
export function estimateShares(articles: IEstimateArticle[]): IEstimateShare[] {
const total = estimateTotal(articles);
if (total <= 0) return [];
return ESTIMATE_KIND_ORDER.map((kind) => {
const amount = articles.filter((a) => a.kind === kind).reduce((sum, a) => sum + articleAmount(a), 0);
const label = kind === 'other' ? 'Другие расходы' : ESTIMATE_KINDS[kind].label;
return { kind, label, amount, share: amount / total };
}).filter((s) => s.amount > 0);
}
export interface IProjectShare {
/** Название объекта; '' — расходы без привязки к объекту. */
project: string;
amount: number;
}
export const NO_PROJECT_LABEL = 'Без привязки к объекту';
/**
* Точка маршрута, к которой относится позиция: у суточных/проживания — сама
* точка, у участка пути — пункт назначения (обратный участок ведёт в город
* отправления — его относим к точке, откуда уезжают). '' — без привязки.
*/
function itemStopId(kind: EstimateKind, item: IEstimateItem, route: ITripRoute): string {
const linkId = resolveLinkId(kind, item.linkId, route);
if (!linkId || ESTIMATE_KINDS[kind].link !== 'leg') return linkId;
const leg = routeLegs(route).find((l) => l.id === linkId);
if (!leg) return '';
return route.stops.some((s) => s.id === leg.toId) ? leg.toId : leg.fromId;
}
/** Обойти суммы включённых позиций с точкой, к которой каждая относится. */
function forEachItemStop(
articles: IEstimateArticle[],
route: ITripRoute,
visit: (stopId: string, amount: number) => void
): void {
articles
.filter((a) => a.enabled)
.forEach((article) => article.items.forEach((item) => visit(itemStopId(article.kind, item, route), itemAmount(item))));
}
/** Расходы по каждой точке маршрута (id точки → сумма) — подписи под точками на схеме. */
export function stopTotals(articles: IEstimateArticle[], route: ITripRoute): Map<string, number> {
const totals = new Map<string, number>();
forEachItemStop(articles, route, (stopId, amount) => {
if (stopId) totals.set(stopId, (totals.get(stopId) || 0) + amount);
});
return totals;
}
/**
* Итог по объектам — для разноски затрат по проектам. Порядок — как точки в
* маршруте, «без привязки» (страховка, виза…) — в конце. Две точки на одном
* объекте складываются.
*/
export function projectShares(articles: IEstimateArticle[], route: ITripRoute): IProjectShare[] {
const totals = new Map<string, number>();
forEachItemStop(articles, route, (stopId, amount) => {
const project = route.stops.find((s) => s.id === stopId)?.project.trim() || '';
totals.set(project, (totals.get(project) || 0) + amount);
});
const order = [...route.stops.map((s) => s.project.trim()), ''];
return Array.from(totals.entries())
.filter(([, amount]) => amount > 0)
.map(([project, amount]) => ({ project, amount }))
.sort((a, b) => order.indexOf(a.project) - order.indexOf(b.project));
}
// ---------------------------------------------------------------------------
// Хранение
// ---------------------------------------------------------------------------
/** Подпись привязки позиции для печати и SP: «Атырау · Нефтебаза-2» / «Алматы → Атырау». */
export function itemLinkLabel(kind: EstimateKind, item: IEstimateItem, route: ITripRoute): string {
const linkId = resolveLinkId(kind, item.linkId, route);
return linkOptions(kind, route).find((o) => o.id === linkId)?.label || '';
}
/** Смета читаемым текстом для колонки SP, по позиции на строку. */
export function formatEstimateText(articles: IEstimateArticle[], route: ITripRoute): string {
const lines: string[] = [];
articles
.filter((a) => a.enabled)
.forEach((article) =>
article.items
.filter((item) => itemAmount(item) > 0)
.forEach((item) => {
const where = itemLinkLabel(article.kind, item, route);
const title = where ? `${articleTitle(article)} (${where})` : articleTitle(article);
const calc = itemCalculation(item);
const amount = `${formatAmount(itemAmount(item))} ₸`;
lines.push(calc ? `${title}: ${calc} = ${amount}` : `${title}: ${amount}`);
})
);
return lines.join('\n');
}
/**
* Форма JSON-колонки сметы: статьи + итог. Итог кладём рядом, чтобы отчёты могли
* читать колонку без пересчёта; форма всегда считает его заново (estimateTotal).
*/
interface IEstimateJson {
articles: IEstimateArticle[];
total: number;
}
export function serializeEstimate(articles: IEstimateArticle[]): string {
const json: IEstimateJson = { articles, total: estimateTotal(articles) };
return JSON.stringify(json);
}
const text = (value: unknown): string => (typeof value === 'string' ? value : '');
function isKind(value: unknown): value is EstimateKind {
return typeof value === 'string' && value in ESTIMATE_KINDS;
}
function toItem(value: unknown): IEstimateItem | undefined {
if (!value || typeof value !== 'object') return undefined;
const raw = value as Record<string, unknown>;
const mode: AmountMode = raw.mode === 'calc' ? 'calc' : 'total';
return {
id: text(raw.id) || newId('item'),
linkId: text(raw.linkId),
mode,
amount: text(raw.amount),
quantity: text(raw.quantity),
price: text(raw.price),
comment: text(raw.comment)
};
}
function toArticle(value: unknown): IEstimateArticle | undefined {
if (!value || typeof value !== 'object') return undefined;
const raw = value as Record<string, unknown>;
if (!isKind(raw.kind)) return undefined;
const items = Array.isArray(raw.items) ? raw.items.map(toItem).filter((i): i is IEstimateItem => !!i) : [];
return {
id: text(raw.id) || raw.kind,
kind: raw.kind,
title: text(raw.title),
enabled: raw.enabled !== false,
items
};
}
/**
* Разобрать TripEstimateJson. Неизвестные/битые статьи отбрасываем; недостающую
* основную статью добавляем пустой, чтобы переключатель «нужно» всегда был на месте.
*/
export function parseEstimate(raw: unknown): IEstimateArticle[] {
let parsed: unknown;
try {
parsed = typeof raw === 'string' && raw.trim() ? JSON.parse(raw) : undefined;
} catch {
parsed = undefined;
}
const rawArticles = parsed && typeof parsed === 'object' ? (parsed as Partial<IEstimateJson>).articles : undefined;
if (!Array.isArray(rawArticles)) return EMPTY_ESTIMATE;
const articles = rawArticles.map(toArticle).filter((a): a is IEstimateArticle => !!a);
const missingCore = EMPTY_ESTIMATE.filter((core) => !articles.some((a) => a.kind === core.kind));
return [...missingCore, ...articles].sort(
(a, b) => ESTIMATE_KIND_ORDER.indexOf(a.kind) - ESTIMATE_KIND_ORDER.indexOf(b.kind)
);
}
@@ -0,0 +1,62 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
import { parseTasks } from './tripData';
import { parseRoute } from './route';
import { parseEstimate } from './estimate';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
/**
* Прочитать заявку на командировку по id. listId — партиция, где лежит именно
* эта заявка (см. UnpaidLeave/load.ts): задан — читаем по url списка, иначе из
* текущей партиции. Маршрут и смета — из JSON-колонок; плоские колонки
* (TripCity, Project, даты…) — производные для отчётов, форма их не читает.
*/
export async function loadBusinessTripRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items
.getById(itemId)
.select(
'Id',
'SubmitDate',
'Position',
'Organization',
'TripRouteJson',
'TripPurpose',
'TripTasksJson',
'TripEstimateJson',
'TripObligationAccepted',
'RequestStatus',
'RequestUniqueCode',
'Employee/EMail'
)
.expand('Employee')()) as Record<string, unknown>;
const employee = item.Employee as { EMail?: string } | undefined;
const data: IFormData = {
...EMPTY_DATA,
// Колонка «только дата» — время в ответе нулевое, берём дату как есть.
SubmitDate: typeof item.SubmitDate === 'string' ? item.SubmitDate.slice(0, 10) : '',
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '',
Route: parseRoute(item.TripRouteJson),
TripPurpose: (item.TripPurpose as string) || '',
Tasks: parseTasks(item.TripTasksJson),
Estimate: parseEstimate(item.TripEstimateJson),
TripObligationAccepted: item.TripObligationAccepted === true
};
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || ''
};
}
@@ -0,0 +1,321 @@
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'hr-business-trip';
/** Заголовок формы, просмотра и элемента списка. */
export const FORM_TITLE = 'Заявка на командировку';
// ---------------------------------------------------------------------------
// Маршрут
// ---------------------------------------------------------------------------
/** Точка маршрута — место, куда едет сотрудник: город + объект + даты пребывания. */
export interface IRouteStop {
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
id: string;
city: string;
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
project: string;
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
projectId: string;
/** YYYY-MM-DD — прибытие в точку. */
dateFrom: string;
/** YYYY-MM-DD — отъезд из точки. */
dateTo: string;
}
/**
* Маршрут: откуда выезжает сотрудник, точки по порядку и возвращается ли он
* обратно. Участки пути (переезды) между точками не хранятся списком — они
* выводятся из точек (см. route.ts/routeLegs); хранится только вид транспорта
* участка по его ключу.
*/
export interface ITripRoute {
/** Город отправления (обычно город работы). */
origin: string;
stops: IRouteStop[];
/** Обратный путь из последней точки в город отправления. */
returnToOrigin: boolean;
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
legTransport: Record<string, string>;
}
/** Ключ «точки» города отправления в ключах участков. */
export const ORIGIN_ID = 'origin';
/**
* Вид транспорта участка. Предположение: в бланке список не задан — варианты
* взяты типовые; поправить здесь, если кадры используют другие.
*/
export const TRANSPORT_TYPES: ReadonlyArray<string> = [
'Авиа',
'Железнодорожный',
'Автобус',
'Служебный автотранспорт',
'Личный автотранспорт'
];
/** Короткие подписи для схемы маршрута — полные не помещаются на линию между точками. */
export const TRANSPORT_SHORT: Record<string, string> = {
Авиа: 'Авиа',
Железнодорожный: 'Ж/д',
Автобус: 'Автобус',
'Служебный автотранспорт': 'Служебное авто',
'Личный автотранспорт': 'Личное авто'
};
// ---------------------------------------------------------------------------
// Смета
// ---------------------------------------------------------------------------
/** Вид статьи сметы. 'other' — «Другой расход» со своим названием, таких может быть несколько. */
export type EstimateKind = 'perDiem' | 'travel' | 'accommodation' | 'insurance' | 'visa' | 'other';
/**
* Как введена сумма позиции: 'total' — сразу итоговой суммой, 'calc' — количеством
* и ценой (форма только перемножает введённое, ничего не подставляет сама).
*/
export type AmountMode = 'total' | 'calc';
/** К чему привязана позиция: к точке маршрута, к участку пути или ни к чему. */
export type EstimateLinkTarget = 'stop' | 'leg' | 'none';
/** Позиция статьи: одна гостиница, один участок пути, суточные в одной точке и т.п. */
export interface IEstimateItem {
id: string;
/** id точки маршрута или ключ участка (см. EstimateLinkTarget статьи); '' — не привязана. */
linkId: string;
mode: AmountMode;
/** Все числа — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнено. */
amount: string;
quantity: string;
price: string;
/** Гостиница, номер рейса, пояснение — необязательно. */
comment: string;
}
/** Статья сметы — заголовок с позициями внутри. */
export interface IEstimateArticle {
/** Стабильный ключ статьи. У основных статей = kind. */
id: string;
kind: EstimateKind;
/** Название — только у «Другого расхода»; у остальных подпись из ESTIMATE_KINDS. */
title: string;
/**
* «Нужно» — переключатель основных статей (суточные/проезд/проживание).
* Добавленные статьи всегда true: ненужную такую статью просто удаляют.
*/
enabled: boolean;
items: IEstimateItem[];
}
export interface IEstimateKindInfo {
label: string;
/** true — основная статья (переключатель «нужно»); false — добавляется «+» и удаляется. */
core: boolean;
/** Способ ввода по умолчанию — тот, которым такую сумму обычно считают. */
defaultMode: AmountMode;
link: EstimateLinkTarget;
quantityLabel: string;
priceLabel: string;
commentPlaceholder: string;
/** Подпись кнопки добавления позиции. */
addItemLabel: string;
}
export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
perDiem: {
label: 'Суточные',
core: true,
defaultMode: 'calc',
link: 'stop',
quantityLabel: 'Дней',
priceLabel: 'Ставка в день',
commentPlaceholder: 'Пояснение (необязательно)',
addItemLabel: 'Ещё ставка суточных'
},
travel: {
label: 'Проезд',
core: true,
defaultMode: 'total',
link: 'leg',
quantityLabel: 'Билетов',
priceLabel: 'Цена билета',
commentPlaceholder: 'Рейс, поезд, пояснение (необязательно)',
addItemLabel: 'Ещё участок пути'
},
accommodation: {
label: 'Проживание',
core: true,
defaultMode: 'calc',
link: 'stop',
quantityLabel: 'Ночей',
priceLabel: 'Цена за ночь',
commentPlaceholder: 'Гостиница, адрес (необязательно)',
addItemLabel: 'Ещё гостиница'
},
insurance: {
label: 'Страховка',
core: false,
defaultMode: 'total',
link: 'none',
quantityLabel: 'Полисов',
priceLabel: 'Цена полиса',
commentPlaceholder: 'Страховая компания, полис (необязательно)',
addItemLabel: 'Ещё полис'
},
visa: {
label: 'Виза',
core: false,
defaultMode: 'total',
link: 'none',
quantityLabel: 'Виз',
priceLabel: 'Сбор за визу',
commentPlaceholder: 'Страна, тип визы (необязательно)',
addItemLabel: 'Ещё виза'
},
other: {
label: 'Другой расход',
core: false,
defaultMode: 'total',
link: 'stop',
quantityLabel: 'Количество',
priceLabel: 'Цена',
commentPlaceholder: 'Пояснение (необязательно)',
addItemLabel: 'Ещё позиция'
}
};
/** Порядок статей в смете, в итоге и в печати. */
export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
'perDiem',
'travel',
'accommodation',
'insurance',
'visa',
'other'
];
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
// ---------------------------------------------------------------------------
// Форма
// ---------------------------------------------------------------------------
/**
* Данные формы. Маршрут и смета хранятся JSON-колонками (TripRouteJson /
* TripEstimateJson, см. submit.ts / load.ts); плоские колонки для отчётов
* (города, объекты, даты) выводятся из маршрута при сохранении. Employee —
* e-mail сотрудника (то, что отдаёт PeopleField); по нему резолвится маршрут
* согласования, поэтому заявку можно подать и за другого сотрудника.
*/
export interface IFormData {
/**
* Дата подачи, YYYY-MM-DD — ставится сегодняшней автоматически, пока заявку
* можно править (новая / черновик / доработка), см. shared/submitDate.ts.
*/
SubmitDate: string;
/** E-mail командируемого (People). Пусто — не выбран. */
Employee: string;
Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string;
Route: ITripRoute;
TripPurpose: string;
/** Задание на командировку — список задач по порядку. */
Tasks: string[];
/** Смета: основные статьи + добавленные (суммы вводятся вручную). */
Estimate: IEstimateArticle[];
/** Сотрудник принял обязательство по авансовому отчёту (см. OBLIGATION_TEXT). */
TripObligationAccepted: boolean;
}
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/**
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля
* отдельный обработчик. Не передан (режим чтения) — правка через set.
*/
onEmployeeChange?: (login: string) => void;
}
export const DEFAULT_PURPOSE = 'Производственная необходимость';
/** Первая точка новой заявки. id фиксированный: у новой заявки на неё ссылаются позиции сметы. */
export const FIRST_STOP_ID = 'stop-1';
export const EMPTY_ROUTE: ITripRoute = {
origin: '',
stops: [{ id: FIRST_STOP_ID, city: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
returnToOrigin: true,
legTransport: {}
};
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
return {
id: `${kind}-1`,
linkId,
mode: ESTIMATE_KINDS[kind].defaultMode,
amount: '',
quantity: '',
price: '',
comment: ''
};
}
function coreArticle(kind: EstimateKind, linkId: string): IEstimateArticle {
return { id: kind, kind, title: '', enabled: true, items: [emptyItem(kind, linkId)] };
}
/**
* Смета новой заявки: основные статьи включены, по одной позиции, привязанной к
* первой точке / первому участку. Страховка и виза добавляются по необходимости.
* Ключ первого участка — `origin>stop-1` (см. route.ts/legId).
*/
export const EMPTY_ESTIMATE: IEstimateArticle[] = [
coreArticle('perDiem', FIRST_STOP_ID),
coreArticle('travel', `${ORIGIN_ID}>${FIRST_STOP_ID}`),
coreArticle('accommodation', FIRST_STOP_ID)
];
export const EMPTY_DATA: IFormData = {
SubmitDate: '',
Employee: '',
Position: '',
Organization: '',
Route: EMPTY_ROUTE,
TripPurpose: DEFAULT_PURPOSE,
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
Tasks: [''],
Estimate: EMPTY_ESTIMATE,
TripObligationAccepted: false
};
/** Текст обязательства из бумажного бланка. */
export const OBLIGATION_TEXT =
'Обязуюсь в течение 3 (трёх) рабочих дней после прибытия из командировки предоставить авансовый ' +
'отчёт по расходу денежных сумм, оригиналы отчётных документов, отчёт о командировке. В случае ' +
'отсутствия отчётных документов / части документов, подтверждающих правомерность расходов, а также ' +
'подписанного руководителем отчёта о командировке, прошу удержать неподтверждённую сумму с заработной платы.';
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
export const PROJECT_PLACEHOLDER = 'Начните вводить название или код объекта…';
@@ -0,0 +1,32 @@
{
"dataListTitle": "Заявка на командировку — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "SubmitDate", "title": "Дата подачи", "type": "DateTime", "dateOnly": true },
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "internalName": "TripProjects", "title": "Объекты командировки", "type": "Note" },
{ "internalName": "TripStartDate", "title": "Командировка с", "type": "DateTime", "dateOnly": true },
{ "internalName": "TripEndDate", "title": "Командировка по", "type": "DateTime", "dateOnly": true },
{ "internalName": "TripDaysCount", "title": "Дней командировки", "type": "Number" },
{ "internalName": "TripPurpose", "title": "Цель командировки", "type": "Note" },
{ "internalName": "TripTasks", "title": "Задание на командировку", "type": "Note" },
{ "internalName": "TripTasksJson", "title": "Задание на командировку (JSON)", "type": "Note" },
{ "internalName": "TripEstimate", "title": "Смета на командировку", "type": "Note" },
{ "internalName": "TripEstimateJson", "title": "Смета на командировку (JSON)", "type": "Note" },
{ "internalName": "TripEstimateTotal", "title": "Итого по смете, тенге", "type": "Number" },
{ "internalName": "TripObligationAccepted", "title": "Обязательство по авансовому отчёту принято", "type": "Boolean" }
]
}
@@ -0,0 +1,66 @@
import { EMPTY_ROUTE, ITripRoute } from './model';
import {
createStop,
legLabel,
parseRoute,
routeCities,
routeLegs,
routePeriod,
routeProjects,
routeText,
serializeRoute,
stopNights
} from './route';
const ROUTE: ITripRoute = {
origin: 'Алматы',
stops: [
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа' }
};
describe('участки пути', () => {
it('туда, между точками и обратно', () => {
const legs = routeLegs(ROUTE);
expect(legs.map((l) => l.id)).toEqual(['origin>s1', 's1>s2', 's2>origin']);
expect(legs.map(legLabel)).toEqual(['Алматы → Атырау', 'Атырау → Актау', 'Актау → Алматы']);
});
it('без возвращения — без обратного участка', () => {
expect(routeLegs({ ...ROUTE, returnToOrigin: false }).map((l) => l.id)).toEqual(['origin>s1', 's1>s2']);
});
});
describe('сводка маршрута', () => {
it('ночи, период, города и объекты', () => {
expect(stopNights(ROUTE.stops[0])).toBe(3);
expect(routePeriod(ROUTE)).toEqual({ start: '2026-10-05', end: '2026-10-10' });
expect(routeCities(ROUTE)).toBe('Атырау, Актау');
expect(routeProjects(ROUTE)).toEqual(['Нефтебаза-2', 'Порт Курык']);
});
it('текст для SP', () => {
expect(routeText(ROUTE)).toBe(
'Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Актау (Порт Курык, 08.10.2026–10.10.2026) → Алматы'
);
});
it('новая точка начинается с даты отъезда из предыдущей', () => {
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
expect(createStop(undefined).dateFrom).toBe('');
});
});
describe('хранение маршрута', () => {
it('сериализация и разбор дают то же самое', () => {
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
});
it('битый JSON или маршрут без точек — маршрут новой заявки', () => {
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
});
});
@@ -0,0 +1,169 @@
import { formatIsoDate } from '../../shared/dateUtils';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model';
import { tripDays } from './tripData';
/** Участок пути (переезд) между двумя соседними точками маршрута. */
export interface IRouteLeg {
/** Ключ участка `откуда>куда` — на него ссылаются вид транспорта и позиции «Проезда». */
id: string;
fromId: string;
toId: string;
from: string;
to: string;
}
export function legId(fromId: string, toId: string): string {
return `${fromId}>${toId}`;
}
/** Уникальный ключ для новой точки / позиции: время + случайный хвост, чтобы два подряд не совпали. */
export function newId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
}
const ORIGIN_FALLBACK = 'Город отправления';
const STOP_FALLBACK = 'Точка маршрута';
function stopCity(stop: IRouteStop): string {
return stop.city.trim() || STOP_FALLBACK;
}
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
export function stopLabel(stop: IRouteStop): string {
const project = stop.project.trim();
return project ? `${stopCity(stop)} · ${project}` : stopCity(stop);
}
/**
* Участки пути по порядку: город отправления → точки → (обратно в город
* отправления). Выводятся из точек, а не хранятся: добавил/удалил точку —
* участки пересобрались сами.
*/
export function routeLegs(route: ITripRoute): IRouteLeg[] {
const origin = { id: ORIGIN_ID, name: route.origin.trim() || ORIGIN_FALLBACK };
const points = [origin, ...route.stops.map((s) => ({ id: s.id, name: stopCity(s) }))];
if (route.returnToOrigin) points.push(origin);
const legs: IRouteLeg[] = [];
for (let i = 1; i < points.length; i++) {
const from = points[i - 1];
const to = points[i];
legs.push({ id: legId(from.id, to.id), fromId: from.id, toId: to.id, from: from.name, to: to.name });
}
return legs;
}
/** Подпись участка: «Алматы → Атырау». */
export function legLabel(leg: IRouteLeg): string {
return `${leg.from} → ${leg.to}`;
}
/** Ночей в точке: с 05.10 по 08.10 — 3 ночи. Пустые/перепутанные даты — undefined. */
export function stopNights(stop: IRouteStop): number | undefined {
const days = tripDays(stop.dateFrom, stop.dateTo);
return days === undefined ? undefined : days - 1;
}
/** Период всей поездки: прибытие в первую точку — отъезд из последней. */
export function routePeriod(route: ITripRoute): { start: string; end: string } {
const starts = route.stops.map((s) => s.dateFrom).filter(Boolean).sort();
const ends = route.stops.map((s) => s.dateTo).filter(Boolean).sort();
return { start: starts[0] || '', end: ends[ends.length - 1] || '' };
}
const unique = (values: string[]): string[] =>
values.map((v) => v.trim()).filter((v, i, all) => v.length > 0 && all.indexOf(v) === i);
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
export function routeCities(route: ITripRoute): string {
return unique(route.stops.map((s) => s.city)).join(', ');
}
/** Объекты точек (без повторов) — для колонки TripProjects. */
export function routeProjects(route: ITripRoute): string[] {
return unique(route.stops.map((s) => s.project));
}
function stopDates(stop: IRouteStop): string {
if (!stop.dateFrom && !stop.dateTo) return '';
return `${formatIsoDate(stop.dateFrom)}–${formatIsoDate(stop.dateTo)}`;
}
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
export function routeText(route: ITripRoute): string {
const parts: string[] = [route.origin.trim() || ORIGIN_FALLBACK];
route.stops.forEach((stop) => {
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
parts.push(details ? `${stopCity(stop)} (${details})` : stopCity(stop));
});
if (route.returnToOrigin) parts.push(route.origin.trim() || ORIGIN_FALLBACK);
return parts.join(' → ');
}
/**
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки:
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым.
*/
export function createStop(previous: IRouteStop | undefined): IRouteStop {
return {
id: newId('stop'),
city: '',
project: '',
projectId: '',
dateFrom: previous?.dateTo || '',
dateTo: ''
};
}
export function serializeRoute(route: ITripRoute): string {
return JSON.stringify(route);
}
const text = (value: unknown): string => (typeof value === 'string' ? value : '');
function toStop(value: unknown): IRouteStop | undefined {
if (!value || typeof value !== 'object') return undefined;
const raw = value as Record<string, unknown>;
const id = text(raw.id);
if (!id) return undefined;
return {
id,
city: text(raw.city),
project: text(raw.project),
projectId: text(raw.projectId),
dateFrom: text(raw.dateFrom),
dateTo: text(raw.dateTo)
};
}
function toTransport(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {};
const result: Record<string, string> = {};
Object.keys(value as object).forEach((key) => {
const type = (value as Record<string, unknown>)[key];
if (typeof type === 'string') result[key] = type;
});
return result;
}
/** Разобрать TripRouteJson. Битый JSON или маршрут без точек — маршрут новой заявки. */
export function parseRoute(raw: unknown): ITripRoute {
let parsed: unknown;
try {
parsed = typeof raw === 'string' && raw.trim() ? JSON.parse(raw) : undefined;
} catch {
parsed = undefined;
}
if (!parsed || typeof parsed !== 'object') return EMPTY_ROUTE;
const rawRoute = parsed as Record<string, unknown>;
const stops = Array.isArray(rawRoute.stops)
? rawRoute.stops.map(toStop).filter((s): s is IRouteStop => !!s)
: [];
if (stops.length === 0) return EMPTY_ROUTE;
return {
origin: text(rawRoute.origin),
stops,
returnToOrigin: rawRoute.returnToOrigin !== false,
legTransport: toTransport(rawRoute.legTransport)
};
}
@@ -0,0 +1,28 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
/**
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого
* сотрудника (руководитель оформляет за подчинённого) — должность и
* организации подтянутся по выбранному. Дату подачи ставит форма.
*/
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
value={data.Employee}
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<OrganizationField value={data.Organization} />
</FormGrid>
);
@@ -0,0 +1,9 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — грид 1fr 1fr; смета должна занимать обе колонки.
.full {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: $sp-3;
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import styles from './EstimateSection.module.scss';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { EstimateEditor } from '../components/estimate/EstimateEditor';
import { EstimateTable } from '../components/estimate/EstimateTable';
import { EstimateSummary } from '../components/estimate/EstimateSummary';
/**
* Смета на командировку. Заполнение — карточками статей с позициями, привязанными
* к точкам и участкам маршрута (EstimateEditor); просмотр и печать — таблицей.
* Итог с долями и разбивкой по объектам — в обоих режимах.
*/
export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title="Смета на командировку" icon="calculator">
<div className={styles.full}>
{readOnly ? (
<>
<EstimateTable articles={data.Estimate} route={data.Route} />
<EstimateSummary articles={data.Estimate} route={data.Route} />
</>
) : (
<EstimateEditor data={data} set={set} />
)}
</div>
</CollapseSection>
);
};
@@ -0,0 +1,12 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — грид 1fr 1fr; текст занимает обе колонки.
.text {
grid-column: 1 / -1;
margin: 0;
font-family: $font-sans;
font-size: $fs-sm;
line-height: 1.55;
color: $gray-700;
text-align: justify;
}
@@ -0,0 +1,19 @@
import * as React from 'react';
import styles from './ObligationSection.module.scss';
import { Checkbox, CollapseSection, FormField } from '../../../shared/ui';
import { ISectionProps, OBLIGATION_LABEL, OBLIGATION_TEXT } from '../model';
/** Обязательство по авансовому отчёту из бланка — без согласия заявку не отправить. */
export const ObligationSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Обязательство" icon="document">
<p className={styles.text}>{OBLIGATION_TEXT}</p>
<FormField label="Подтверждение" htmlFor="TripObligationAccepted" required full>
<Checkbox
id="TripObligationAccepted"
checked={data.TripObligationAccepted}
onChange={(v) => set('TripObligationAccepted', v)}
label={OBLIGATION_LABEL}
/>
</FormField>
</CollapseSection>
);
@@ -0,0 +1,9 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — грид 1fr 1fr; маршрут занимает обе колонки.
.full {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: $sp-3;
}
@@ -0,0 +1,49 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import styles from './RouteSection.module.scss';
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { RouteEditor } from '../components/route/RouteEditor';
import { RouteScheme } from '../components/route/RouteScheme';
import { RouteViewMode, RouteViewSwitch } from '../components/route/RouteViewSwitch';
import { stopCardDomId } from '../components/route/RouteStopCard';
/**
* Куда и зачем: маршрут (города + объекты + даты) и цель командировки. Маршрут —
* в двух видах: «Форма» для заполнения и «Схема» для просмотра. Заявитель
* начинает с формы (он пришёл заполнять), согласующий — со схемы (ему важнее
* увидеть маршрут целиком).
*/
export const RouteSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const readOnly = useFormReadOnly();
const [mode, setMode] = useState<RouteViewMode>(readOnly ? 'scheme' : 'form');
// Точка, к которой прокрутить после переключения со схемы на форму.
const [focusStopId, setFocusStopId] = useState<string | undefined>(undefined);
useEffect(() => {
if (mode !== 'form' || !focusStopId) return;
document.getElementById(stopCardDomId(focusStopId))?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setFocusStopId(undefined);
}, [mode, focusStopId]);
const openStopInForm = (stopId: string): void => {
setFocusStopId(stopId);
setMode('form');
};
return (
<CollapseSection title="Маршрут" icon="vehicle">
<div className={styles.full}>
<RouteViewSwitch value={mode} onChange={setMode} />
{mode === 'form' ? (
<RouteEditor route={data.Route} onChange={(route) => set('Route', route)} context={context} />
) : (
<RouteScheme route={data.Route} articles={data.Estimate} onSelectStop={openStopInForm} />
)}
</div>
<FormField label="Цель" htmlFor="TripPurpose" required full>
<TextArea id="TripPurpose" value={data.TripPurpose} onChange={(v) => set('TripPurpose', v)} rows={2} />
</FormField>
</CollapseSection>
);
};
@@ -0,0 +1,64 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — грид 1fr 1fr; список должен занимать обе колонки.
.full {
grid-column: 1 / -1;
}
.list {
display: flex;
flex-direction: column;
gap: $sp-2;
}
// Номер | поле | корзина. Поле тянется, остальное фиксировано — влезает и в телефон.
.row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) 30px;
align-items: center;
gap: $sp-2;
}
.num {
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 600;
color: $gray-500;
text-align: center;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.readList {
margin: 0;
padding-left: $sp-6;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
li + li {
margin-top: $sp-2;
}
}
.empty {
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,62 @@
import * as React from 'react';
import styles from './TasksSection.module.scss';
import { Button, CollapseSection, FormField, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { filledTasks } from '../tripData';
/** Задание на командировку: нумерованный список задач, строки добавляются и удаляются. */
export const TasksSection: React.FC<ISectionProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
const tasks = data.Tasks;
const setTask = (index: number, value: string): void =>
set('Tasks', tasks.map((task, i) => (i === index ? value : task)));
const addTask = (): void => set('Tasks', [...tasks, '']);
const removeTask = (index: number): void => set('Tasks', tasks.filter((_, i) => i !== index));
if (readOnly) {
const filled = filledTasks(tasks);
return (
<CollapseSection title="Задание на командировку" icon="clipboard">
<div className={styles.full}>
{filled.length === 0 ? (
<div className={styles.empty}>Задачи не указаны</div>
) : (
<ol className={styles.readList}>
{filled.map((task, i) => (
<li key={i}>{task}</li>
))}
</ol>
)}
</div>
</CollapseSection>
);
}
return (
<CollapseSection title="Задание на командировку" icon="clipboard">
<FormField label="Задачи" fieldKey="Tasks" required full>
<div className={styles.list}>
{tasks.map((task, i) => (
<div className={styles.row} key={i}>
<span className={styles.num}>{i + 1}</span>
<TextInput value={task} onChange={(v) => setTask(i, v)} placeholder="Опишите задачу" ariaLabel={`Задача ${i + 1}`} />
{/* Последнюю строку не удаляем — пустой список выглядел бы как сломанный блок. */}
{tasks.length > 1 && (
<button type="button" className={styles.remove} title="Удалить задачу" onClick={() => removeTask(i)}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
))}
</div>
</FormField>
<div className={styles.full}>
<Button variant="secondary" size="sm" onClick={addTask}>
<Icon name="plus" size="sm" />
Добавить задачу
</Button>
</div>
</CollapseSection>
);
};
@@ -0,0 +1,83 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee';
import { submitDateOnSave } from '../../shared/submitDate';
import { formatIsoDate } from '../../shared/dateUtils';
import { FORM_ID, FORM_TITLE, IFormData } from './model';
import { filledTasks, formatTasks, tripDays } from './tripData';
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute } from './route';
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
export function businessTripApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Заголовок элемента (системная колонка Title): «Заявка на командировку — ФИО — Атырау, Актау, 05.10.2026». */
function buildTitle(data: IFormData, employeeName: string): string {
const start = routePeriod(data.Route).start;
const where = [routeCities(data.Route), start ? formatIsoDate(start) : ''].filter(Boolean).join(', ');
return where ? `${FORM_TITLE} — ${employeeName} — ${where}` : `${FORM_TITLE} — ${employeeName}`;
}
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
const route = data.Route;
const period = routePeriod(route);
const projects = routeProjects(route);
// Основной объект — первой точки маршрута: колонка Project у всех форм одна,
// полный список объектов — в TripProjects.
const firstStop = route.stops[0];
return {
Title: buildTitle(data, employeeName),
RequestStatus: status,
SubmitDate: submitDateOnSave(status, data.SubmitDate) || null,
EmployeeId: employeeId,
Position: data.Position || null,
Organization: data.Organization || null,
TripRouteJson: serializeRoute(route),
TripRoute: routeText(route) || null,
TripCity: routeCities(route) || null,
Project: firstStop?.project || null,
ProjectId: firstStop?.projectId || null,
TripProjects: projects.join('\n') || null,
TripStartDate: period.start || null,
TripEndDate: period.end || null,
TripDaysCount: tripDays(period.start, period.end) ?? null,
TripPurpose: data.TripPurpose || null,
// Читаемая строка — для представлений списка; источник для формы — JSON.
TripTasks: formatTasks(data.Tasks) || null,
TripTasksJson: JSON.stringify(filledTasks(data.Tasks)),
TripEstimate: formatEstimateText(data.Estimate, route) || null,
TripEstimateJson: serializeEstimate(data.Estimate),
// Итог дублируется числовой колонкой — для отчётов и сортировки в SP.
TripEstimateTotal: estimateTotal(data.Estimate),
TripObligationAccepted: data.TripObligationAccepted
};
}
/**
* Сохранить заявку на командировку: содержимое + маршрут согласования (согласование и исполнение).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём.
* executors — шаги фазы «Исполнение»; пусто — после согласования заявка сразу «Согласовано».
* listId — партиция обновляемой заявки (см. UnpaidLeave/submit.ts, тот же смысл).
*/
export async function saveBusinessTripRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveItemResult> {
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(businessTripApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,40 @@
import { formatTasks, parseTasks, plural, tripDays } from './tripData';
describe('tripDays', () => {
it('считает дни включительно', () => {
expect(tripDays('2026-10-01', '2026-10-03')).toBe(3);
expect(tripDays('2026-10-01', '2026-10-01')).toBe(1);
});
it('через границу месяца', () => {
expect(tripDays('2026-09-29', '2026-10-02')).toBe(4);
});
it('пустые или перепутанные даты — undefined', () => {
expect(tripDays('', '2026-10-03')).toBeUndefined();
expect(tripDays('2026-10-05', '2026-10-03')).toBeUndefined();
});
});
describe('plural', () => {
it('1 / 2–4 / 5+, и 11–14 — как 5+', () => {
const nights: [string, string, string] = ['ночь', 'ночи', 'ночей'];
expect(plural(1, nights)).toBe('1 ночь');
expect(plural(3, nights)).toBe('3 ночи');
expect(plural(5, nights)).toBe('5 ночей');
expect(plural(12, nights)).toBe('12 ночей');
expect(plural(21, nights)).toBe('21 ночь');
});
});
describe('задачи', () => {
it('читаемый текст без пустых строк и с нумерацией', () => {
expect(formatTasks(['Приёмка работ', ' ', 'Совещание'])).toBe('1. Приёмка работ\n2. Совещание');
});
it('пустой или битый JSON — одна пустая строка', () => {
expect(parseTasks('')).toEqual(['']);
expect(parseTasks('nope')).toEqual(['']);
expect(parseTasks(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,49 @@
const MS_PER_DAY = 86400000;
/**
* Число дней командировки включительно (с 1 по 3 число — 3 дня). Пустые или
* перепутанные даты — undefined: показывать и сохранять нечего.
*/
export function tripDays(startIso: string, endIso: string): number | undefined {
if (!startIso || !endIso) return undefined;
const diff = new Date(endIso).getTime() - new Date(startIso).getTime();
if (!Number.isFinite(diff) || diff < 0) return undefined;
return Math.round(diff / MS_PER_DAY) + 1;
}
/**
* Число со словом в правильной форме: plural(3, ['ночь', 'ночи', 'ночей']) → «3 ночи».
* forms — для 1 / 2–4 / 5+ (с учётом 11–14: «11 ночей»).
*/
export function plural(count: number, forms: [string, string, string]): string {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} ${forms[0]}`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} ${forms[1]}`;
return `${count} ${forms[2]}`;
}
/** Задачи без пустых строк — пустые строки в форме не сохраняем. */
export function filledTasks(tasks: string[]): string[] {
return tasks.map((t) => t.trim()).filter((t) => t.length > 0);
}
/** Задачи для читаемой колонки SP: «1. …\n2. …». */
export function formatTasks(tasks: string[]): string {
return filledTasks(tasks)
.map((task, i) => `${i + 1}. ${task}`)
.join('\n');
}
/** Разобрать TripTasksJson. Битый/пустой JSON — одна пустая строка, как у новой заявки. */
export function parseTasks(raw: unknown): string[] {
if (typeof raw !== 'string' || raw.trim() === '') return [''];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [''];
const tasks = parsed.filter((t): t is string => typeof t === 'string');
return tasks.length > 0 ? tasks : [''];
} catch {
return [''];
}
}
@@ -0,0 +1,47 @@
import * as React from 'react';
import { useEffect } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate';
import { IFormData } from './model';
type SetData = React.Dispatch<React.SetStateAction<IFormData>>;
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
getEmployeeOrganizations(context, email)
.then((records) => {
if (!isAlive()) return;
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
})
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
.catch(() => undefined);
}
/**
* Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь,
* его должность, организации и сегодняшняя дата подачи. Возвращает обработчик
* выбора другого сотрудника — он подтягивает должность и организации выбранного.
*/
export function useTripEmployee(context: WebPartContext, editId: number | undefined, setData: SetData): (login: string) => void {
useEffect(() => {
if (editId) return;
let alive = true;
const email = context.pageContext.user.email;
setData((prev) => ({ ...prev, SubmitDate: todaySubmitDate(), Employee: prev.Employee || email }));
fillProfile(context, email, setData, () => alive);
return () => {
alive = false;
};
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
}, [editId, context, setData]);
return (login: string): void => {
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' }));
if (login) fillProfile(context, login, setData, () => true);
};
}
@@ -0,0 +1,87 @@
import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model';
import { createArticle } from './estimate';
import { draftIssues, submitIssues } from './validation';
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
const ROUTE: ITripRoute = {
origin: 'Алматы',
stops: [{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
};
const FILLED: IFormData = {
...EMPTY_DATA,
Employee: 'ivanov@tnsu.kz',
Position: 'Инженер ПТО',
Route: ROUTE,
Tasks: ['Приёмка работ'],
// Смета не обязательна: все основные статьи выключены.
Estimate: EMPTY_DATA.Estimate.map(off),
TripObligationAccepted: true
};
const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field);
describe('validation: форма', () => {
it('заполненная заявка — без проблем', () => {
expect(submitIssues(FILLED)).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('черновику нужен только сотрудник', () => {
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
});
it('пустые задачи и обязательство', () => {
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']);
});
});
describe('validation: маршрут', () => {
it('объект только из справочника', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
});
it('у каждого участка нужен вид транспорта', () => {
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
});
it('отъезд раньше прибытия блокирует даже черновик', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '2026-10-01' }] };
expect(fields(draftIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-to']);
});
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', city: 'Актау', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
});
});
describe('validation: смета', () => {
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
it('включённая статья без суммы — подсвечивается пустое поле', () => {
const filledQty = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '5' }] };
const data = { ...FILLED, Estimate: [filledQty, off(travel), off(accommodation)] };
expect(fields(submitIssues(data))).toEqual([`item-${perDiem.items[0].id}-price`]);
});
it('при нескольких вариантах позицию нужно привязать', () => {
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] };
expect(fields(submitIssues(data))).toEqual([`item-${travel.items[0].id}-link`]);
});
it('другой расход требует название и сумму', () => {
const other = createArticle('other', ROUTE);
const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] };
expect(fields(submitIssues(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
});
});
@@ -0,0 +1,125 @@
import type { IFieldIssue } from '../../shared/ui';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData';
import { legLabel, routeLegs } from './route';
import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate';
/**
* Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField:
* по ним поле подсвечивается и к нему прокручивается форма.
*/
export const fieldKey = {
origin: 'route-origin',
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
legTransport: (legId: string): string => `leg-${legId}`,
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
itemQuantity: (item: IEstimateItem): string => `item-${item.id}-quantity`,
itemPrice: (item: IEstimateItem): string => `item-${item.id}-price`
};
/** «Точка 2 (Актау)» — как точку видно в форме. */
function stopName(stop: IRouteStop, index: number): string {
return stop.city.trim() ? `Точка ${index + 1} (${stop.city.trim()})` : `Точка ${index + 1}`;
}
/** Дата окончания раньше начала — блокирует даже черновик: такие даты не сохранить осмысленно. */
function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
const issues: IFieldIssue[] = [];
route.stops.forEach((stop, i) => {
if (stop.dateFrom && stop.dateTo && stop.dateTo < stop.dateFrom) {
issues.push({ field: fieldKey.stopTo(stop), label: stopName(stop, i), message: 'дата отъезда раньше даты прибытия' });
}
});
return issues;
}
/** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined): IFieldIssue[] {
const label = stopName(stop, index);
const issues: IFieldIssue[] = [];
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
}
return issues;
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
function routeIssues(route: ITripRoute): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1])));
issues.push(...stopDateOrderIssues(route));
routeLegs(route).forEach((leg) => {
if (!route.legTransport[leg.id]) {
issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' });
}
});
return issues;
}
/** Почему у позиции нет суммы — подсвечиваем именно пустое поле. */
function amountIssue(item: IEstimateItem, label: string): IFieldIssue {
if (item.mode === 'total') return { field: fieldKey.itemAmount(item), label, message: 'укажите сумму' };
if (!Number(item.quantity)) return { field: fieldKey.itemQuantity(item), label, message: 'укажите количество' };
return { field: fieldKey.itemPrice(item), label, message: 'укажите цену' };
}
/**
* Смета. Сама не обязательна — выключенные статьи не проверяются; но у включённой
* или добавленной статьи каждая позиция должна иметь сумму и (если вариантов
* больше одного) привязку к точке/участку, у «Другого расхода» — название.
*/
function estimateIssues(articles: IEstimateArticle[], route: ITripRoute): IFieldIssue[] {
const issues: IFieldIssue[] = [];
articles
.filter((a) => a.enabled)
.forEach((article) => {
if (article.kind === 'other' && !article.title.trim()) {
issues.push({ field: fieldKey.articleTitle(article), label: 'Смета: другой расход', message: 'укажите название' });
}
const needsLink = ESTIMATE_KINDS[article.kind].link !== 'none' && linkOptions(article.kind, route).length > 0;
article.items.forEach((item) => {
const where = itemLinkLabel(article.kind, item, route);
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
issues.push({ field: fieldKey.itemLink(item), label, message: 'выберите, к чему относится расход' });
}
if (itemAmount(item) <= 0) issues.push(amountIssue(item, label));
});
});
return issues;
}
/** Что мешает сохранить черновик (пусто — сохранить можно). */
export function draftIssues(data: IFormData): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
return [...issues, ...stopDateOrderIssues(data.Route)];
}
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
export function submitIssues(data: IFormData): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
issues.push(...routeIssues(data.Route));
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
if (filledTasks(data.Tasks).length === 0) {
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });
}
issues.push(...estimateIssues(data.Estimate, data.Route));
if (!data.TripObligationAccepted) {
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство', message: 'подтвердите согласие' });
}
return issues;
}
@@ -10,7 +10,7 @@
## Маршрут
- Фазы: согласование (напр. непосредственный руководитель) + исполнение (напр. кадры) —
обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')`
в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
в общем `usePhasedRoute` (shared/approval, паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может
править (новая / черновик / возврат), исполнителей — нет (только чтение).
- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение».
@@ -25,7 +25,8 @@ import {
initialSubmitStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
resolveWriteListId,
usePhasedRoute
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
@@ -46,7 +47,6 @@ import { loadSkudExplanationRequest } from './load';
import { submittedConflictMessage } from './submittedDays';
import { getSubmittedDays } from './submittedDays.service';
import { useSubmittedDays } from './useSubmittedDays';
import { useSkudRoute } from './useSkudRoute';
import { todayDateKey } from './skud/skudDays';
import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
@@ -129,7 +129,7 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
}, [editId, context]);
// Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки.
const { approvers: matrix, executors } = useSkudRoute({
const { approvers: matrix, executors } = usePhasedRoute({
context,
approval,
requestId: editId,
@@ -9,7 +9,8 @@ import {
StatusBadge,
ApprovalHistory,
notifyActiveStep,
useRequestTypeId
useRequestTypeId,
usePhasedRoute
} from '../../shared/approval';
import type { ISiteUser } from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -23,7 +24,6 @@ import { useStableCallback } from '../../shared/useStableCallback';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { loadSkudExplanationRequest } from './load';
import { skudExplanationApprovalService } from './submit';
import { useSkudRoute } from './useSkudRoute';
import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
import { ExplanationSection } from './sections/ExplanationSection';
@@ -65,7 +65,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const { approvers, executors } = useSkudRoute({
const { approvers, executors } = usePhasedRoute({
context,
approval,
requestId: itemId,
@@ -37,6 +37,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
'hr-skud-explanation': React.lazy(
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
),
'hr-business-trip': React.lazy(
() => import(/* webpackChunkName: 'form-business-trip' */ './BusinessTrip/BusinessTripForm')
),
'wh-tmc-transfer': React.lazy(
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
),
@@ -56,6 +56,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
'hr-skud-explanation': React.lazy(
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
),
'hr-business-trip': React.lazy(
() => import(/* webpackChunkName: 'view-business-trip' */ './BusinessTrip/BusinessTripView')
),
'wh-tmc-transfer': React.lazy(
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
),
@@ -120,6 +120,7 @@ export class ApproverService {
// activateNextQueued затем активирует первого делеганта (у него меньший order).
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
await this.approval.activateNextQueued(requestId);
await this.touchRequest(requestId);
emitRequestsChanged();
}
@@ -171,6 +172,7 @@ export class ApproverService {
const row = this.editableExecutionRow(rows, rowId);
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
await this.approval.replaceApprover(rowId, { email: user.email, position });
await this.touchRequest(requestId);
emitRequestsChanged();
return row.status === REQUEST_STATUS.onExecution;
}
@@ -205,6 +207,7 @@ export class ApproverService {
await this.approval.activateNextQueued(requestId);
const rowsAfter = await this.approval.getForRequest(requestId);
await this.touchRequest(requestId);
emitRequestsChanged();
return rowsAfter
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
@@ -270,4 +273,16 @@ export class ApproverService {
RequestStatus: status,
});
}
// Права на заявку новым участникам маршрута выдаёт бэкенд по вебхуку на
// изменение элемента СПИСКА ДАННЫХ. Делегирование и замена/удаление
// исполнителя меняют только список согласования — без этой записи вебхук не
// приходит, и новый человек видит «Элемент не найден». Пишем текущий статус
// как есть: значение не меняется, но у элемента появляется новая версия.
private async touchRequest(id: number): Promise<void> {
const item = (await this.dataList().items.getById(id).select('RequestStatus')()) as {
RequestStatus?: string;
};
await this.updateRequestStatus(id, item.RequestStatus || '');
}
}
@@ -70,6 +70,8 @@ export {
useFormExecutors,
useViewExecutors
} from './useRouteExecutors';
export { usePhasedRoute } from './usePhasedRoute';
export type { IPhasedRoute } from './usePhasedRoute';
export { resolveApproverOrgInfo } from './approverOrgInfo';
export type { IApproverOrgInfo } from './approverOrgInfo';
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
@@ -1,17 +1,15 @@
import { useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ApprovalListService,
IMatrixRow,
getPlannedMatrix,
resolveApproverOrgInfo,
useMatrixEditor
} from '../../shared/approval';
import { APPROVAL_PHASE } from './models/statuses';
import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalListService } from './approvalList.service';
import { getPlannedMatrix } from './approvalSubmission.service';
import { resolveApproverOrgInfo } from './approverOrgInfo';
import { useMatrixEditor } from './useMatrixEditor';
type MatrixEditor = ReturnType<typeof useMatrixEditor>;
export interface ISkudRoute {
export interface IPhasedRoute {
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
approvers: MatrixEditor;
/** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */
@@ -23,6 +21,7 @@ interface IRouteSource {
approval: ApprovalListService;
/** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */
requestId: number | undefined;
/** E-mail сотрудника, по которому резолвится планируемый маршрут. */
employeeEmail: string;
requestTypeId: number | undefined;
/** Можно ли править согласующих. */
@@ -33,15 +32,18 @@ interface IRouteSource {
reloadSeq?: number;
}
const byPhase = (execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) => (rows) =>
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
const byPhase =
(execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) =>
(rows: IMatrixRow[]): IMatrixRow[] =>
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
/**
* Маршрут объяснительной по двум фазам. Без отдельного запроса фазы
* 'Execution' getPlannedMatrix исполнителей не резолвит — шаг кадров из
* маршрута «пропадал» бы. Паттерн тот же, что в Referral.
* Маршрут заявки по двум фазам: согласование + исполнение. Без отдельного
* запроса фазы 'Execution' getPlannedMatrix исполнителей не резолвит — шаг
* исполнения из маршрута «пропадал» бы. Маршрут настраивают в админке
* approval-route, форма его только получает.
*/
export function useSkudRoute(source: IRouteSource): ISkudRoute {
export function usePhasedRoute(source: IRouteSource): IPhasedRoute {
const { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source;
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
@@ -33,6 +33,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
'hr-vacation': 'Заявка на отпуск',
'hr-unpaid-leave': 'Отпуск без содержания',
'hr-skud-explanation': 'Объяснительная по СКУД',
'hr-business-trip': 'Заявка на командировку',
'it-1c-ticket': 'Заявка в отдел 1С',
'it-workplace-setup': 'Организация рабочего места',
'recruiting-hire': 'Прием на работу',