Слияние main в feature/vacation-edo-signing: usePhasedRoute из main с editable-исполнителями, экспорты useRouteExecutors сохранены
Tests / frontend-test (pull_request) Successful in 1m25s
Tests / frontend-test (pull_request) Successful in 1m25s
This commit is contained in:
@@ -49,6 +49,7 @@
|
|||||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||||
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
|
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
icon: 'user-check',
|
icon: 'user-check',
|
||||||
comingSoon: true
|
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: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true },
|
||||||
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+135
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+33
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+122
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+7
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+38
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+150
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+72
@@ -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>
|
||||||
|
);
|
||||||
+119
@@ -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>
|
||||||
|
);
|
||||||
+298
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+73
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+38
@@ -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')`
|
обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')`
|
||||||
в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
|
в общем `usePhasedRoute` (shared/approval, паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
|
||||||
- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может
|
- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может
|
||||||
править (новая / черновик / возврат), исполнителей — нет (только чтение).
|
править (новая / черновик / возврат), исполнителей — нет (только чтение).
|
||||||
- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение».
|
- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение».
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
initialSubmitStatus,
|
initialSubmitStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId
|
resolveWriteListId,
|
||||||
|
usePhasedRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
@@ -46,7 +47,6 @@ import { loadSkudExplanationRequest } from './load';
|
|||||||
import { submittedConflictMessage } from './submittedDays';
|
import { submittedConflictMessage } from './submittedDays';
|
||||||
import { getSubmittedDays } from './submittedDays.service';
|
import { getSubmittedDays } from './submittedDays.service';
|
||||||
import { useSubmittedDays } from './useSubmittedDays';
|
import { useSubmittedDays } from './useSubmittedDays';
|
||||||
import { useSkudRoute } from './useSkudRoute';
|
|
||||||
import { todayDateKey } from './skud/skudDays';
|
import { todayDateKey } from './skud/skudDays';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { DaysSection } from './sections/DaysSection';
|
import { DaysSection } from './sections/DaysSection';
|
||||||
@@ -129,7 +129,7 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
|
|||||||
}, [editId, context]);
|
}, [editId, context]);
|
||||||
|
|
||||||
// Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки.
|
// Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки.
|
||||||
const { approvers: matrix, executors } = useSkudRoute({
|
const { approvers: matrix, executors } = usePhasedRoute({
|
||||||
context,
|
context,
|
||||||
approval,
|
approval,
|
||||||
requestId: editId,
|
requestId: editId,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId,
|
||||||
|
usePhasedRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import type { ISiteUser } from '../../shared/approval';
|
import type { ISiteUser } from '../../shared/approval';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
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 { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadSkudExplanationRequest } from './load';
|
import { loadSkudExplanationRequest } from './load';
|
||||||
import { skudExplanationApprovalService } from './submit';
|
import { skudExplanationApprovalService } from './submit';
|
||||||
import { useSkudRoute } from './useSkudRoute';
|
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { DaysSection } from './sections/DaysSection';
|
import { DaysSection } from './sections/DaysSection';
|
||||||
import { ExplanationSection } from './sections/ExplanationSection';
|
import { ExplanationSection } from './sections/ExplanationSection';
|
||||||
@@ -65,7 +65,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
|||||||
|
|
||||||
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
|
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const { approvers, executors } = useSkudRoute({
|
const { approvers, executors } = usePhasedRoute({
|
||||||
context,
|
context,
|
||||||
approval,
|
approval,
|
||||||
requestId: itemId,
|
requestId: itemId,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
|||||||
'hr-skud-explanation': React.lazy(
|
'hr-skud-explanation': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
|
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
|
||||||
),
|
),
|
||||||
|
'hr-business-trip': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'form-business-trip' */ './BusinessTrip/BusinessTripForm')
|
||||||
|
),
|
||||||
'wh-tmc-transfer': React.lazy(
|
'wh-tmc-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
|||||||
'hr-skud-explanation': React.lazy(
|
'hr-skud-explanation': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
|
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
|
||||||
),
|
),
|
||||||
|
'hr-business-trip': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-business-trip' */ './BusinessTrip/BusinessTripView')
|
||||||
|
),
|
||||||
'wh-tmc-transfer': React.lazy(
|
'wh-tmc-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export class ApproverService {
|
|||||||
// activateNextQueued затем активирует первого делеганта (у него меньший order).
|
// activateNextQueued затем активирует первого делеганта (у него меньший order).
|
||||||
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
||||||
await this.approval.activateNextQueued(requestId);
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
await this.touchRequest(requestId);
|
||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ export class ApproverService {
|
|||||||
const row = this.editableExecutionRow(rows, rowId);
|
const row = this.editableExecutionRow(rows, rowId);
|
||||||
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
|
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
|
||||||
await this.approval.replaceApprover(rowId, { email: user.email, position });
|
await this.approval.replaceApprover(rowId, { email: user.email, position });
|
||||||
|
await this.touchRequest(requestId);
|
||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
return row.status === REQUEST_STATUS.onExecution;
|
return row.status === REQUEST_STATUS.onExecution;
|
||||||
}
|
}
|
||||||
@@ -205,6 +207,7 @@ export class ApproverService {
|
|||||||
await this.approval.activateNextQueued(requestId);
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
|
||||||
const rowsAfter = await this.approval.getForRequest(requestId);
|
const rowsAfter = await this.approval.getForRequest(requestId);
|
||||||
|
await this.touchRequest(requestId);
|
||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
return rowsAfter
|
return rowsAfter
|
||||||
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
|
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
|
||||||
@@ -270,4 +273,16 @@ export class ApproverService {
|
|||||||
RequestStatus: status,
|
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,
|
useFormExecutors,
|
||||||
useViewExecutors
|
useViewExecutors
|
||||||
} from './useRouteExecutors';
|
} from './useRouteExecutors';
|
||||||
|
export { usePhasedRoute } from './usePhasedRoute';
|
||||||
|
export type { IPhasedRoute } from './usePhasedRoute';
|
||||||
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||||
export type { IApproverOrgInfo } from './approverOrgInfo';
|
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
||||||
|
|||||||
+16
-14
@@ -1,17 +1,15 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import {
|
import { APPROVAL_PHASE } from './models/statuses';
|
||||||
APPROVAL_PHASE,
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
ApprovalListService,
|
import { ApprovalListService } from './approvalList.service';
|
||||||
IMatrixRow,
|
import { getPlannedMatrix } from './approvalSubmission.service';
|
||||||
getPlannedMatrix,
|
import { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||||
resolveApproverOrgInfo,
|
import { useMatrixEditor } from './useMatrixEditor';
|
||||||
useMatrixEditor
|
|
||||||
} from '../../shared/approval';
|
|
||||||
|
|
||||||
type MatrixEditor = ReturnType<typeof useMatrixEditor>;
|
type MatrixEditor = ReturnType<typeof useMatrixEditor>;
|
||||||
|
|
||||||
export interface ISkudRoute {
|
export interface IPhasedRoute {
|
||||||
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
||||||
approvers: MatrixEditor;
|
approvers: MatrixEditor;
|
||||||
/** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */
|
/** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */
|
||||||
@@ -23,6 +21,7 @@ interface IRouteSource {
|
|||||||
approval: ApprovalListService;
|
approval: ApprovalListService;
|
||||||
/** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */
|
/** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */
|
||||||
requestId: number | undefined;
|
requestId: number | undefined;
|
||||||
|
/** E-mail сотрудника, по которому резолвится планируемый маршрут. */
|
||||||
employeeEmail: string;
|
employeeEmail: string;
|
||||||
requestTypeId: number | undefined;
|
requestTypeId: number | undefined;
|
||||||
/** Можно ли править согласующих. */
|
/** Можно ли править согласующих. */
|
||||||
@@ -33,15 +32,18 @@ interface IRouteSource {
|
|||||||
reloadSeq?: number;
|
reloadSeq?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const byPhase = (execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) => (rows) =>
|
const byPhase =
|
||||||
|
(execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) =>
|
||||||
|
(rows: IMatrixRow[]): IMatrixRow[] =>
|
||||||
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
|
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Маршрут объяснительной по двум фазам. Без отдельного запроса фазы
|
* Маршрут заявки по двум фазам: согласование + исполнение. Без отдельного
|
||||||
* 'Execution' getPlannedMatrix исполнителей не резолвит — шаг кадров из
|
* запроса фазы 'Execution' getPlannedMatrix исполнителей не резолвит — шаг
|
||||||
* маршрута «пропадал» бы. Паттерн тот же, что в Referral.
|
* исполнения из маршрута «пропадал» бы. Маршрут настраивают в админке
|
||||||
|
* 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 { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source;
|
||||||
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
||||||
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
||||||
@@ -33,6 +33,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
|||||||
'hr-vacation': 'Заявка на отпуск',
|
'hr-vacation': 'Заявка на отпуск',
|
||||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||||
'hr-skud-explanation': 'Объяснительная по СКУД',
|
'hr-skud-explanation': 'Объяснительная по СКУД',
|
||||||
|
'hr-business-trip': 'Заявка на командировку',
|
||||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||||
'it-workplace-setup': 'Организация рабочего места',
|
'it-workplace-setup': 'Организация рабочего места',
|
||||||
'recruiting-hire': 'Прием на работу',
|
'recruiting-hire': 'Прием на работу',
|
||||||
|
|||||||
Reference in New Issue
Block a user