From e85c01fca36292a3a43f9523d2b217812b08452a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 5 Oct 2026 10:19:57 +0500 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=85=D1=83=D0=BA=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=B7=D0=B0=D0=BC=20?= =?UTF-8?q?(usePhasedRoute)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хук, который грузит маршрут согласования отдельно по фазам «Согласование» и «Исполнение», был в папке «Объяснительной по СКУД». Он понадобился и заявке на командировку, поэтому перенесён в shared/approval под общим именем. Поведение СКУД не меняется — поменялись только импорты. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/SkudExplanation/README.md | 2 +- .../SkudExplanation/SkudExplanationForm.tsx | 6 ++-- .../SkudExplanation/SkudExplanationView.tsx | 6 ++-- .../requestPortal/shared/approval/index.ts | 2 ++ .../approval/usePhasedRoute.ts} | 32 ++++++++++--------- 5 files changed, 26 insertions(+), 22 deletions(-) rename src/webparts/requestPortal/{forms/SkudExplanation/useSkudRoute.ts => shared/approval/usePhasedRoute.ts} (64%) diff --git a/src/webparts/requestPortal/forms/SkudExplanation/README.md b/src/webparts/requestPortal/forms/SkudExplanation/README.md index bb1d83b..6571747 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/README.md +++ b/src/webparts/requestPortal/forms/SkudExplanation/README.md @@ -10,7 +10,7 @@ ## Маршрут - Фазы: согласование (напр. непосредственный руководитель) + исполнение (напр. кадры) — обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')` - в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает. + в общем `usePhasedRoute` (shared/approval, паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает. - Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может править (новая / черновик / возврат), исполнителей — нет (только чтение). - Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение». diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx index 72e9bd1..9f5c7c7 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -23,7 +23,8 @@ import { initialSubmitStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + usePhasedRoute } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; @@ -43,7 +44,6 @@ import { loadSkudExplanationRequest } from './load'; import { submittedConflictMessage } from './submittedDays'; import { getSubmittedDays } from './submittedDays.service'; import { useSubmittedDays } from './useSubmittedDays'; -import { useSkudRoute } from './useSkudRoute'; import { todayDateKey } from './skud/skudDays'; import { EmployeeSection } from './sections/EmployeeSection'; import { DaysSection } from './sections/DaysSection'; @@ -126,7 +126,7 @@ const SkudExplanationForm: React.FC = ({ }, [editId, context]); // Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки. - const { approvers: matrix, executors } = useSkudRoute({ + const { approvers: matrix, executors } = usePhasedRoute({ context, approval, requestId: editId, diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx index d00702b..d903c44 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx @@ -9,7 +9,8 @@ import { StatusBadge, ApprovalHistory, notifyActiveStep, - useRequestTypeId + useRequestTypeId, + usePhasedRoute } from '../../shared/approval'; import type { ISiteUser } from '../../shared/approval'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -23,7 +24,6 @@ import { useStableCallback } from '../../shared/useStableCallback'; import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model'; import { loadSkudExplanationRequest } from './load'; import { skudExplanationApprovalService } from './submit'; -import { useSkudRoute } from './useSkudRoute'; import { EmployeeSection } from './sections/EmployeeSection'; import { DaysSection } from './sections/DaysSection'; import { ExplanationSection } from './sections/ExplanationSection'; @@ -65,7 +65,7 @@ const SkudExplanationView: React.FC = ({ context, ite // decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы. const [decisionSeq, setDecisionSeq] = useState(0); - const { approvers, executors } = useSkudRoute({ + const { approvers, executors } = usePhasedRoute({ context, approval, requestId: itemId, diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 5831fa6..2a4bcc1 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -63,6 +63,8 @@ export { notifyActiveStep } from './notify.service'; export type { INotifyTarget } from './notify.service'; export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; +export { usePhasedRoute } from './usePhasedRoute'; +export type { IPhasedRoute } from './usePhasedRoute'; export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; export { canEditRoute } from './routeEditRights'; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts b/src/webparts/requestPortal/shared/approval/usePhasedRoute.ts similarity index 64% rename from src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts rename to src/webparts/requestPortal/shared/approval/usePhasedRoute.ts index e798198..345e3c4 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts +++ b/src/webparts/requestPortal/shared/approval/usePhasedRoute.ts @@ -1,17 +1,15 @@ import { useCallback } from 'react'; import { WebPartContext } from '@microsoft/sp-webpart-base'; -import { - APPROVAL_PHASE, - ApprovalListService, - IMatrixRow, - getPlannedMatrix, - resolveApproverOrgInfo, - useMatrixEditor -} from '../../shared/approval'; +import { APPROVAL_PHASE } from './models/statuses'; +import { IMatrixRow } from './models/IMatrixRow'; +import { ApprovalListService } from './approvalList.service'; +import { getPlannedMatrix } from './approvalSubmission.service'; +import { resolveApproverOrgInfo } from './approverOrgInfo'; +import { useMatrixEditor } from './useMatrixEditor'; type MatrixEditor = ReturnType; -export interface ISkudRoute { +export interface IPhasedRoute { /** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */ approvers: MatrixEditor; /** Исполнители по маршруту (фаза «Исполнение») — только чтение. */ @@ -23,6 +21,7 @@ interface IRouteSource { approval: ApprovalListService; /** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */ requestId: number | undefined; + /** E-mail сотрудника, по которому резолвится планируемый маршрут. */ employeeEmail: string; requestTypeId: number | undefined; /** Можно ли править согласующих. */ @@ -31,15 +30,18 @@ interface IRouteSource { reloadSeq?: number; } -const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] => - rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution); +const byPhase = + (execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) => + (rows: IMatrixRow[]): IMatrixRow[] => + rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution); /** - * Маршрут объяснительной по двум фазам. Без отдельного запроса фазы - * 'Execution' getPlannedMatrix исполнителей не резолвит — шаг кадров из - * маршрута «пропадал» бы. Паттерн тот же, что в Referral. + * Маршрут заявки по двум фазам: согласование + исполнение. Без отдельного + * запроса фазы 'Execution' getPlannedMatrix исполнителей не резолвит — шаг + * исполнения из маршрута «пропадал» бы. Маршрут настраивают в админке + * approval-route, форма его только получает. */ -export function useSkudRoute(source: IRouteSource): ISkudRoute { +export function usePhasedRoute(source: IRouteSource): IPhasedRoute { const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source; const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId); const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`; From 8eb5b13985a27556213bc1ea3e520dd1c42e68bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 5 Oct 2026 10:20:15 +0500 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D1=83=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цифровая версия бумажной заявки на командировку (отдел кадров). Можно подать за другого сотрудника; маршрут — лентой точек (город + объект из справочника проектов + даты) с видом транспорта на каждом участке и вторым видом «Схема» (SVG, как схема метро). Задание — список задач. Смета — карточками статей (суточные, проезд, проживание, страховка, виза, другие расходы) с позициями, привязанными к точкам и участкам маршрута; суммы только вручную, итог с долями и разбивкой по объектам. Печать — без шапки с адресатом. Плитка пока только для тестировщиков (comingSoon): списки SharePoint не провизионированы, тип заявки и маршрут в админке не заведены. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/forms.md | 1 + src/webparts/requestPortal/data/requests.ts | 8 + .../BusinessTrip/BusinessTripForm.module.scss | 20 ++ .../forms/BusinessTrip/BusinessTripForm.tsx | 280 +++++++++++++++ .../BusinessTrip/BusinessTripView.module.scss | 17 + .../forms/BusinessTrip/BusinessTripView.tsx | 188 ++++++++++ .../forms/BusinessTrip/README.md | 82 +++++ .../BusinessTrip/approval/provisioning.json | 17 + .../components/TransportChips.module.scss | 57 ++++ .../components/TransportChips.tsx | 50 +++ .../BusinessTrip/components/TransportIcon.tsx | 41 +++ .../estimate/AmountFields.module.scss | 135 ++++++++ .../components/estimate/AmountFields.tsx | 87 +++++ .../estimate/EstimateAddBar.module.scss | 33 ++ .../components/estimate/EstimateAddBar.tsx | 30 ++ .../estimate/EstimateArticleCard.module.scss | 122 +++++++ .../estimate/EstimateArticleCard.tsx | 88 +++++ .../estimate/EstimateEditor.module.scss | 7 + .../components/estimate/EstimateEditor.tsx | 43 +++ .../estimate/EstimateItemRow.module.scss | 38 +++ .../components/estimate/EstimateItemRow.tsx | 63 ++++ .../estimate/EstimateSummary.module.scss | 150 ++++++++ .../components/estimate/EstimateSummary.tsx | 73 ++++ .../estimate/EstimateTable.module.scss | 72 ++++ .../components/estimate/EstimateTable.tsx | 59 ++++ .../components/route/RouteEditor.module.scss | 119 +++++++ .../components/route/RouteEditor.tsx | 127 +++++++ .../components/route/RouteLegRow.module.scss | 6 + .../components/route/RouteLegRow.tsx | 25 ++ .../components/route/RouteScheme.module.scss | 298 ++++++++++++++++ .../components/route/RouteScheme.tsx | 63 ++++ .../route/RouteStopCard.module.scss | 73 ++++ .../components/route/RouteStopCard.tsx | 75 ++++ .../route/RouteViewSwitch.module.scss | 38 +++ .../components/route/RouteViewSwitch.tsx | 32 ++ .../components/route/SchemeNodeLabel.tsx | 52 +++ .../components/route/SchemeTrack.tsx | 172 ++++++++++ .../components/route/TransportBadge.tsx | 21 ++ .../components/route/schemeNode.ts | 17 + .../forms/BusinessTrip/estimate.test.ts | 133 ++++++++ .../forms/BusinessTrip/estimate.ts | 297 ++++++++++++++++ .../requestPortal/forms/BusinessTrip/load.ts | 62 ++++ .../requestPortal/forms/BusinessTrip/model.ts | 321 ++++++++++++++++++ .../forms/BusinessTrip/provisioning.json | 32 ++ .../forms/BusinessTrip/route.test.ts | 66 ++++ .../requestPortal/forms/BusinessTrip/route.ts | 169 +++++++++ .../BusinessTrip/sections/EmployeeSection.tsx | 28 ++ .../sections/EstimateSection.module.scss | 9 + .../BusinessTrip/sections/EstimateSection.tsx | 31 ++ .../sections/ObligationSection.module.scss | 12 + .../sections/ObligationSection.tsx | 19 ++ .../sections/RouteSection.module.scss | 9 + .../BusinessTrip/sections/RouteSection.tsx | 49 +++ .../sections/TasksSection.module.scss | 64 ++++ .../BusinessTrip/sections/TasksSection.tsx | 62 ++++ .../forms/BusinessTrip/submit.ts | 83 +++++ .../forms/BusinessTrip/tripData.test.ts | 40 +++ .../forms/BusinessTrip/tripData.ts | 49 +++ .../forms/BusinessTrip/useTripEmployee.ts | 47 +++ .../forms/BusinessTrip/validation.test.ts | 87 +++++ .../forms/BusinessTrip/validation.ts | 125 +++++++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../requestPortal/shared/listTitles.ts | 1 + 64 files changed, 4680 insertions(+) create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/README.md create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/TransportIcon.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateSummary.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateSummary.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateTable.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateTable.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteScheme.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteScheme.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteViewSwitch.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteViewSwitch.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/SchemeNodeLabel.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/SchemeTrack.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/TransportBadge.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/schemeNode.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/estimate.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/load.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/model.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/provisioning.json create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/route.test.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/route.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/EmployeeSection.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/EstimateSection.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/EstimateSection.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/ObligationSection.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/ObligationSection.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/RouteSection.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/RouteSection.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/TasksSection.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/sections/TasksSection.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/submit.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/tripData.test.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/tripData.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/useTripEmployee.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/validation.ts diff --git a/docs/forms.md b/docs/forms.md index 9b924d7..ef4e706 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -49,6 +49,7 @@ | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ | | [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | | [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены | +| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ | diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index a8a8645..6e821cb 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -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 }, { diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx new file mode 100644 index 0000000..c9d6b0a --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx @@ -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 = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId, + onCodeLoaded +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(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) => async (): Promise => { + // 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 => submit.run('draft', save(REQUEST_STATUS.draft), validate); + // Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение». + const handleSubmit = (): Promise => + 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 ( + + + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + + + + + approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение (после согласования)" + roleLabel="Исполнитель" + emptyText="Исполнители не заданы по маршруту." + /> + )} + + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. */} + {editId && ( +
+ +
+ )} + + +
+
+ ); +}; + +export default BusinessTripForm; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.module.scss new file mode 100644 index 0000000..b52e802 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx new file mode 100644 index 0000000..64c4acd --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx @@ -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 = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + const notifyCode = useStableCallback(onCodeLoaded); + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + 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 => approval.searchUsers(query); + + return ( +
+
+ +
+ +
+ + } + headerRight={status ? : undefined} + > + + + + + + + + + + + {!executors.loading && executors.rows.length > 0 && ( + + )} +
+ + {/* История согласования: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */} +
+ +
+ +
+ { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default BusinessTripView; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md new file mode 100644 index 0000000..6179ae7 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -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`) +- «Создать на основе»: нет + +## Не сделано +- Авансовый отчёт после командировки — отдельной формой, пока не придумано как. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/approval/provisioning.json b/src/webparts/requestPortal/forms/BusinessTrip/approval/provisioning.json new file mode 100644 index 0000000..9287889 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/approval/provisioning.json @@ -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" } + ] +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss new file mode 100644 index 0000000..7acf414 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx new file mode 100644 index 0000000..e8bf61b --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx @@ -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 = ({ value, onChange, ariaLabel }) => { + const readOnly = useFormReadOnly(); + + if (readOnly) { + return ( + + + {value || 'не указан'} + + ); + } + + return ( +
+ {TRANSPORT_TYPES.map((type) => { + const selected = type === value; + return ( + + ); + })} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportIcon.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportIcon.tsx new file mode 100644 index 0000000..61b044f --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportIcon.tsx @@ -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 = { + Авиа: PLANE, + Железнодорожный: TRAIN, + Автобус: BUS, + 'Служебный автотранспорт': CAR, + 'Личный автотранспорт': CAR +}; + +/** Иконка вида транспорта; цвет — currentColor, размер задаёт className. */ +export const TransportIcon: React.FC = ({ type, className }) => { + const path = PATH_BY_TYPE[type]; + if (!path) return null; + return ( + + + + ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.module.scss new file mode 100644 index 0000000..3364d29 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.tsx new file mode 100644 index 0000000..6f4b144 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AmountFields.tsx @@ -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) => 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 = ({ id, value, onChange, money }) => ( +
+ +
+); + +/** + * Ввод суммы позиции: сразу итогом или количеством × ценой. Форма ничего не + * подставляет — только перемножает введённое. Значения обоих способов хранятся + * независимо: переключился туда-обратно — ничего не потерял. + */ +export const AmountFields: React.FC = ({ kind, item, onChange }) => { + const info = ESTIMATE_KINDS[kind]; + + return ( +
+
+ {MODES.map(({ mode, label }) => ( + + ))} +
+ + {item.mode === 'total' ? ( +
+ + onChange({ amount: v })} money /> + +
+ ) : ( +
+ + onChange({ quantity: v })} /> + + + × + + + onChange({ price: v })} money /> + + + = + +
+ Итого + {formatAmount(itemAmount(item))} ₸ +
+
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.module.scss new file mode 100644 index 0000000..e2dd939 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.tsx new file mode 100644 index 0000000..b88cc2e --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateAddBar.tsx @@ -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 = ({ articles, onAdd }) => { + const available = SINGLE_OPTIONAL_KINDS.filter((kind) => !articles.some((a) => a.kind === kind)); + const kinds: EstimateKind[] = [...available, 'other']; + + return ( +
+ {kinds.map((kind) => ( + + ))} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.module.scss new file mode 100644 index 0000000..d8a2c58 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx new file mode 100644 index 0000000..3b0dbf7 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx @@ -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 = ({ 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): 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 ( +
+
+ {article.kind === 'other' ? ( +
+ + onChange({ ...article, title: v })} + placeholder="Напр.: пропуск на объект" + /> + +
+ ) : ( + {articleTitle(article)} + )} + +
+ {amount > 0 && {formatAmount(amount)} ₸} + {info.core ? ( + onChange({ ...article, enabled })} + onLabel={article.enabled ? 'Нужно' : 'Не требуется'} + /> + ) : ( + + )} +
+
+ + {article.enabled && ( + <> +
+ {article.items.map((item) => ( + updateItem(item.id, patch)} + onRemove={article.items.length > 1 ? () => removeItem(item.id) : undefined} + /> + ))} +
+ + + )} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.module.scss new file mode 100644 index 0000000..0c2cac3 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.module.scss @@ -0,0 +1,7 @@ +@import '../../../../../../styles/design-tokens'; + +.editor { + display: flex; + flex-direction: column; + gap: $sp-3; +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.tsx new file mode 100644 index 0000000..f433514 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateEditor.tsx @@ -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; + +/** Новая статья встаёт на своё место по 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 = ({ 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 ( +
+ {articles.map((article) => ( + removeArticle(article.id)} + /> + ))} + + +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.module.scss new file mode 100644 index 0000000..5da8dff --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx new file mode 100644 index 0000000..104c89d --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx @@ -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) => void; + /** Не передан — позицию удалить нельзя (она в статье одна). */ + onRemove?: () => void; +} + +/** + * Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и + * пояснение. Выбор «к чему относится» показываем, только когда вариантов + * больше одного — в простой поездке в одну точку выбирать нечего. + */ +export const EstimateItemRow: React.FC = ({ 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 ( +
+ {(showLink || onRemove) && ( +
+ {showLink && ( +
+ +