feat: форма «Заявка на командировку»
Цифровая версия бумажной заявки на командировку (отдел кадров). Можно подать за другого сотрудника; маршрут — лентой точек (город + объект из справочника проектов + даты) с видом транспорта на каждом участке и вторым видом «Схема» (SVG, как схема метро). Задание — список задач. Смета — карточками статей (суточные, проезд, проживание, страховка, виза, другие расходы) с позициями, привязанными к точкам и участкам маршрута; суммы только вручную, итог с долями и разбивкой по объектам. Печать — без шапки с адресатом. Плитка пока только для тестировщиков (comingSoon): списки SharePoint не провизионированы, тип заявки и маршрут в админке не заведены. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,14 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
icon: 'user-check',
|
||||
comingSoon: true
|
||||
},
|
||||
// Пока только тестировщикам: тип заявки и маршрут ещё не заведены в админке approval-route.
|
||||
{
|
||||
id: 'hr-business-trip',
|
||||
categoryId: 'hr',
|
||||
title: 'Заявка на командировку',
|
||||
icon: 'vehicle',
|
||||
comingSoon: true
|
||||
},
|
||||
{ id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true },
|
||||
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', comingSoon: true },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import styles from './BusinessTripForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
FieldIssuesProvider,
|
||||
issueLabels,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
usePhasedRoute,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
initialSubmitStatus,
|
||||
IApproverToWrite,
|
||||
IUseMatrixEditor,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
} from '../../shared/approval';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { submitDateOnOpen } from '../../shared/submitDate';
|
||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { draftIssues, submitIssues } from './validation';
|
||||
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
||||
import { loadBusinessTripRequest } from './load';
|
||||
import { useTripEmployee } from './useTripEmployee';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { RouteSection } from './sections/RouteSection';
|
||||
import { TasksSection } from './sections/TasksSection';
|
||||
import { EstimateSection } from './sections/EstimateSection';
|
||||
import { ObligationSection } from './sections/ObligationSection';
|
||||
|
||||
const toApprovers = (rows: IUseMatrixEditor['rows']): IApproverToWrite[] =>
|
||||
rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order,
|
||||
// Транзитом из маршрута в строку списка: наследование бэкенд применил при resolve.
|
||||
slaHours: r.slaHours,
|
||||
askOverdueReason: r.askOverdueReason,
|
||||
autoApprove: r.autoApprove
|
||||
}));
|
||||
|
||||
const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
// Стабильная обёртка: ссылка onCodeLoaded у родителя меняется на каждом
|
||||
// рендере, а в зависимостях эффекта загрузки она перезапрашивала бы заявку.
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const { setError: setSubmitError } = submit;
|
||||
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
const handleEmployeeChange = useTripEmployee(context, editId, setData);
|
||||
|
||||
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
const requestTypeId = requestTypeIdState.id;
|
||||
|
||||
// Открыта своя заявка на редактирование (черновик/возврат).
|
||||
useEffect(() => {
|
||||
if (!editId) return;
|
||||
let alive = true;
|
||||
loadBusinessTripRequest(editId, editListId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) });
|
||||
setRequestStatus(loaded.status);
|
||||
setRequestCode(loaded.code || undefined);
|
||||
if (loaded.code) notifyCode(loaded.code);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||
|
||||
// Маршрут по выбранному сотруднику; у открытой заявки — из уже записанных строк,
|
||||
// чтобы не затереть правки инициатора.
|
||||
const { approvers: matrix, executors } = usePhasedRoute({
|
||||
context,
|
||||
approval,
|
||||
requestId: editId,
|
||||
employeeEmail: data.Employee,
|
||||
requestTypeId,
|
||||
editable: !readOnly
|
||||
});
|
||||
|
||||
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
|
||||
const draftMissing = draftIssues(data);
|
||||
const submitMissing = submitIssues(data);
|
||||
|
||||
const validate = (): string | undefined => {
|
||||
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
if (submitMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(submitMissing)}.`;
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) =>
|
||||
`Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) =>
|
||||
`Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
};
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
// validate() уже гарантировал requestTypeId, но TS не видит этого через замыкание.
|
||||
const rtId = requestTypeId;
|
||||
if (!rtId) throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
// Текущую партицию спрашиваем прямо перед записью: этот вызов запускает архивацию по порогу.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveBusinessTripRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) setRequestCode(code);
|
||||
if (onSaved) onSaved();
|
||||
// Отправка (на согласование или, без согласующих, сразу на исполнение) — уведомляем активный шаг.
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
// Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение».
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run('submit', save(initialSubmitStatus(matrix.rows.length, executors.rows.length)), validateSubmit);
|
||||
|
||||
const handleDelete = getDraftDeleteHandler({
|
||||
formKey: FORM_ID,
|
||||
requestId,
|
||||
requestStatus,
|
||||
listId: editListId,
|
||||
code: requestCode,
|
||||
submit,
|
||||
onDeleted: onCancel
|
||||
});
|
||||
|
||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||
|
||||
return (
|
||||
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard
|
||||
title={FORM_TITLE}
|
||||
subtitle="Категория: Отдел кадров"
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestCode && <RequestNumber code={requestCode} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">
|
||||
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<RouteSection {...sectionProps} />
|
||||
<TasksSection {...sectionProps} />
|
||||
<EstimateSection {...sectionProps} />
|
||||
<ObligationSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или отклонили. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={draftMissing.length === 0}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Отправить на согласование"
|
||||
/>
|
||||
</FormCard>
|
||||
</FieldIssuesProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessTripForm;
|
||||
@@ -0,0 +1,17 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.state {
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $sp-3;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './BusinessTripView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
ApprovalMatrix,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalHistory,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
usePhasedRoute
|
||||
} from '../../shared/approval';
|
||||
import type { ISiteUser } from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadBusinessTripRequest } from './load';
|
||||
import { businessTripApprovalService } from './submit';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { RouteSection } from './sections/RouteSection';
|
||||
import { TasksSection } from './sections/TasksSection';
|
||||
import { EstimateSection } from './sections/EstimateSection';
|
||||
import { ObligationSection } from './sections/ObligationSection';
|
||||
|
||||
interface IBusinessTripViewProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит эта заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
// В режиме чтения поля не редактируются — обновление пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
/**
|
||||
* Заявка на командировку для согласующего (только чтение). Печать — данные
|
||||
* заявки и маршрут согласования/исполнения вместо подписей бланка; шапки с
|
||||
* адресатом нет (решение Тимура: бумажный бланк один в один не повторяем).
|
||||
*/
|
||||
const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const approval = useMemo(() => businessTripApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const { approvers, executors } = usePhasedRoute({
|
||||
context,
|
||||
approval,
|
||||
requestId: itemId,
|
||||
employeeEmail: data.Employee,
|
||||
requestTypeId,
|
||||
editable: false,
|
||||
reloadSeq: decisionSeq
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
loadBusinessTripRequest(itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, notifyCode]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Alert variant="err">{error}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context };
|
||||
const searchUsers = (query: string): Promise<ISiteUser[]> => approval.searchUsers(query);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintButton />
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<RouteSection {...sectionProps} />
|
||||
<TasksSection {...sectionProps} />
|
||||
<EstimateSection {...sectionProps} />
|
||||
<ObligationSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={searchUsers} />
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={searchUsers}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
search={searchUsers}
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessTripView;
|
||||
@@ -0,0 +1,82 @@
|
||||
# Заявка на командировку (hr-business-trip)
|
||||
|
||||
**Назначение:** заявка о направлении сотрудника в служебную командировку — куда, когда,
|
||||
с каким заданием и сметой расходов.
|
||||
**Категория:** Отдел кадров (плитка «Заявка на командировку», пока `comingSoon` — только тестировщикам)
|
||||
**Списки SP:** «Заявка на командировку — данные» / «Заявка на командировку — согласование»
|
||||
**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при заведении
|
||||
типа в админке approval-route с `pageUrl` «hr-business-trip».
|
||||
|
||||
## Маршрут
|
||||
- Маршрут настраивается в админке approval-route другим человеком — форма его только получает.
|
||||
- Фазы: согласование + исполнение, обе грузятся общим `usePhasedRoute` (shared/approval).
|
||||
Нет шагов исполнения — после согласования статус «Согласовано»; нет согласующих —
|
||||
`initialSubmitStatus` сразу ставит «Выполнение».
|
||||
- Планируемый маршрут резолвится по e-mail **выбранного сотрудника**; согласующих заявитель
|
||||
может править (новая / черновик / доработка), исполнителей — нет.
|
||||
|
||||
## Особое поведение
|
||||
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
|
||||
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
|
||||
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
|
||||
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
|
||||
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
|
||||
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
|
||||
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
|
||||
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
|
||||
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
|
||||
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
|
||||
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
|
||||
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
|
||||
«дни · объекты · переезды · итог». Клик по точке — форма с прокруткой к ней. Заявитель
|
||||
начинает с формы, согласующий (режим чтения) — со схемы. Широкий экран — слева направо,
|
||||
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
|
||||
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
|
||||
прибытие раньше отъезда из предыдущей точки — ошибка.
|
||||
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
|
||||
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
|
||||
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
|
||||
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
||||
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
||||
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**: форма
|
||||
ничего не подставляет (решение Тимура — корректно подставлять нечего; про МРП забыть).
|
||||
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Нужно / Не требуется»,
|
||||
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
|
||||
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
|
||||
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути», «+ Ещё ставка
|
||||
суточных»). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
|
||||
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
|
||||
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
|
||||
- Сумма позиции — «Суммой» или «Кол-во × цена» (только перемножение введённого), подписи
|
||||
над полями. Пробовали таблицу «в строку» и смету «по точкам маршрута» — Тимур вернул
|
||||
карточки (10-05): «самые понятные».
|
||||
- Итог на лету: полоса долей по видам статей (палитра проверена dataviz-валидатором) и,
|
||||
если объектов несколько, **итог по объектам** (`projectShares`; участок — к объекту
|
||||
пункта назначения, обратный — к объекту, откуда уезжают; страховка/виза — «без объекта»).
|
||||
- Смета сама не обязательна, но у включённой статьи каждая позиция — с суммой и
|
||||
привязкой, у «Другого расхода» — название.
|
||||
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
|
||||
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
|
||||
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
|
||||
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
|
||||
можно править (см. shared/submitDate.ts).
|
||||
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
|
||||
Черновику нужен только сотрудник.
|
||||
- **Печать** — данные заявки и маршрут согласования/исполнения, **без шапки с адресатом**:
|
||||
бумажный бланк не повторяется (решение Тимура).
|
||||
- **Заголовок элемента** (`Title`): `Заявка на командировку — {ФИО} — {города}, {дата начала}`.
|
||||
- Новые колонки — с префиксом `Trip*`: `StartDate` в других формах уже занят с другим
|
||||
заголовком (см. грабли коллизий internalName).
|
||||
|
||||
## Секции формы
|
||||
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
|
||||
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
|
||||
ObligationSection (текст обязательства + согласие).
|
||||
|
||||
## Точки входа
|
||||
- rptype: `hr-business-trip`
|
||||
- Режим согласующего (просмотр): есть (`BusinessTripView`)
|
||||
- «Создать на основе»: нет
|
||||
|
||||
## Не сделано
|
||||
- Авансовый отчёт после командировки — отдельной формой, пока не придумано как.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dataListTitle": "Заявка на командировку — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на командировку — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-full;
|
||||
background: $white;
|
||||
color: $gray-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: .18s;
|
||||
|
||||
&:hover {
|
||||
border-color: $gold-400;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $gold-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Режим чтения: плашка-метка, не кнопка.
|
||||
.static {
|
||||
padding: 3px 10px;
|
||||
font-size: $fs-label;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: $navy-800;
|
||||
background: $navy-800;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $navy-700;
|
||||
background: $navy-700;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TransportChips.module.scss';
|
||||
import { useFormReadOnly } from '../../../shared/ui';
|
||||
import { TRANSPORT_TYPES } from '../model';
|
||||
import { TransportIcon } from './TransportIcon';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Подпись группы для экранных дикторов, напр. «Вид транспорта: Алматы → Атырау». */
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вид транспорта плашками: выбор в один клик и все варианты сразу на виду
|
||||
* (вместо выпадающего списка). В режиме чтения — только выбранная плашка.
|
||||
*/
|
||||
export const TransportChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<span className={`${styles.chip} ${styles.selected} ${styles.static}`}>
|
||||
<TransportIcon type={value} className={styles.icon} />
|
||||
{value || 'не указан'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chips} role="radiogroup" aria-label={ariaLabel}>
|
||||
{TRANSPORT_TYPES.map((type) => {
|
||||
const selected = type === value;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={selected ? `${styles.chip} ${styles.selected}` : styles.chip}
|
||||
onClick={() => onChange(type)}
|
||||
>
|
||||
<TransportIcon type={type} className={styles.icon} />
|
||||
{type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Значение из TRANSPORT_TYPES. Неизвестное — иконки нет. */
|
||||
type: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пути иконок транспорта (Material Icons, лицензия Apache 2.0, viewBox 0 0 24 24).
|
||||
* В ui-kit таких иконок нет, а для схемы маршрута и плашек выбора они — главный
|
||||
* способ узнать вид транспорта с первого взгляда.
|
||||
*/
|
||||
const PLANE =
|
||||
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z';
|
||||
const TRAIN =
|
||||
'M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z';
|
||||
const BUS =
|
||||
'M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z';
|
||||
const CAR =
|
||||
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
|
||||
|
||||
/** Вид транспорта → иконка. Ключи — значения TRANSPORT_TYPES из model.ts. */
|
||||
const PATH_BY_TYPE: Record<string, string> = {
|
||||
Авиа: PLANE,
|
||||
Железнодорожный: TRAIN,
|
||||
Автобус: BUS,
|
||||
'Служебный автотранспорт': CAR,
|
||||
'Личный автотранспорт': CAR
|
||||
};
|
||||
|
||||
/** Иконка вида транспорта; цвет — currentColor, размер задаёт className. */
|
||||
export const TransportIcon: React.FC<Props> = ({ type, className }) => {
|
||||
const path = PATH_BY_TYPE[type];
|
||||
if (!path) return null;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path d={path} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
+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;
|
||||
}
|
||||
@@ -37,6 +37,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
||||
'hr-skud-explanation': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
|
||||
),
|
||||
'hr-business-trip': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-business-trip' */ './BusinessTrip/BusinessTripForm')
|
||||
),
|
||||
'wh-tmc-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
||||
),
|
||||
|
||||
@@ -56,6 +56,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
||||
'hr-skud-explanation': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
|
||||
),
|
||||
'hr-business-trip': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-business-trip' */ './BusinessTrip/BusinessTripView')
|
||||
),
|
||||
'wh-tmc-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
||||
),
|
||||
|
||||
@@ -33,6 +33,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
||||
'hr-vacation': 'Заявка на отпуск',
|
||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||
'hr-skud-explanation': 'Объяснительная по СКУД',
|
||||
'hr-business-trip': 'Заявка на командировку',
|
||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||
'it-workplace-setup': 'Организация рабочего места',
|
||||
'recruiting-hire': 'Прием на работу',
|
||||
|
||||
Reference in New Issue
Block a user