diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index 4312453..25c7d92 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -50,8 +50,11 @@ каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д, такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен. Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь» - (`legDepartureTime`, `DEPARTURE_TIMES`), необязательно — повторный клик снимает выбор. На - схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали. + (`legDepartureTime`, `DEPARTURE_TIMES`), тоже обязательно — нужно тревел-координатору под + билеты. На схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали. + - **Переезд — внутри карточки точки** (с 10-23, `LegFields`): блок «Как добраться: Алматы → + Астана» внизу карточки точки, куда едут; обратный — под переключателем «Возвращение». + Раньше участок был отдельной строкой на линии маршрута — его пропускали при заполнении. - Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx index 98f3e1c..40bd2ab 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx @@ -12,15 +12,15 @@ interface Props { } /** - * Предпочтительное время выезда плашками. Поле необязательное: повторный клик по - * выбранной плашке снимает выбор. В режиме чтения — только выбранная плашка - * (не указано — ничего не показываем). + * Предпочтительное время выезда плашками (обязательно — см. validation.ts). + * Повторный клик по выбранной плашке снимает выбор. В режиме чтения — только + * выбранная плашка. */ export const DepartureTimeChips: React.FC = ({ value, onChange, ariaLabel }) => { const readOnly = useFormReadOnly(); if (readOnly) { - return value ? {value} : null; + return {value || 'не указано'}; } return ( diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/LegFields.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/route/LegFields.module.scss new file mode 100644 index 0000000..7417a42 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/LegFields.module.scss @@ -0,0 +1,18 @@ +@import '../../../../../../styles/design-tokens'; + +// Переезд внутри карточки точки — отдельная плашка с фоном, чтобы её не пропускали. +.leg { + display: flex; + flex-direction: column; + gap: $sp-3; + padding: $sp-3; + border-radius: $r-sm; + background: $gray-50; +} + +.title { + font-family: $font-sans; + font-size: $fs-sm; + font-weight: 600; + color: $navy-900; +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/LegFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/LegFields.tsx new file mode 100644 index 0000000..10388ea --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/LegFields.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import styles from './LegFields.module.scss'; +import { FormField } from '../../../../shared/ui'; +import { IRouteLeg, legLabel } from '../../route'; +import { fieldKey } from '../../validation'; +import { TransportChips } from '../TransportChips'; +import { DepartureTimeChips } from '../DepartureTimeChips'; + +interface Props { + leg: IRouteLeg; + transport: string; + onTransportChange: (value: string) => void; + departureTime: string; + onDepartureTimeChange: (value: string) => void; +} + +/** + * Переезд «как добраться»: вид транспорта и время выезда (оба обязательны). + * Живёт внутри карточки точки, куда едут (обратный — в блоке «Возвращение»): + * отдельной строкой на линии маршрута его пропускали при заполнении. + */ +export const LegFields: React.FC = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => ( +
+ Как добраться: {legLabel(leg)} + + + + + + +
+); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.module.scss index c99052f..dc503a1 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.module.scss +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.module.scss @@ -108,7 +108,11 @@ $marker-size: 26px; } } +// Возвращение: переключатель, а под ним — обратный переезд (транспорт, время выезда). .end { + display: flex; + flex-direction: column; + gap: $sp-3; padding-top: $sp-2; } diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx index 0a614f2..a23c9c7 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx @@ -7,7 +7,7 @@ import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model'; import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } from '../../route'; import { fieldKey } from '../../validation'; import { RouteStopCard } from './RouteStopCard'; -import { RouteLegRow } from './RouteLegRow'; +import { LegFields } from './LegFields'; interface Props { route: ITripRoute; @@ -33,9 +33,10 @@ const TimelineRow: React.FC<{ marker: MarkerKind; label?: string; children: Reac /** * Маршрут командировки лентой: город отправления → точки (город + объект + - * даты) → обратно. Между точками — участки пути с видом транспорта; участки - * не хранятся, а выводятся из точек (route.ts/routeLegs). Простая поездка — одна - * точка; «+ Добавить точку» — для поездок по нескольким объектам. + * даты) → обратно. Переезд (транспорт, время выезда) — внутри карточки точки, куда + * едут, обратный — в блоке «Возвращение»: отдельной строкой на линии его + * пропускали. Участки не хранятся, а выводятся из точек (route.ts/routeLegs). + * Простая поездка — одна точка; «+ Добавить точку» — для поездок по нескольким объектам. */ export const RouteEditor: React.FC = ({ route, onChange, context }) => { const readOnly = useFormReadOnly(); @@ -60,17 +61,15 @@ export const RouteEditor: React.FC = ({ route, onChange, context }) => { const setDepartureTime = (id: string, value: string): void => onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } }); - const legRow = (leg: IRouteLeg | undefined): React.ReactNode => + const legFields = (leg: IRouteLeg | undefined): React.ReactNode => leg && ( - - setTransport(leg.id, v)} - departureTime={route.legDepartureTime[leg.id] || ''} - onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)} - /> - + setTransport(leg.id, v)} + departureTime={route.legDepartureTime[leg.id] || ''} + onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)} + /> ); return ( @@ -89,18 +88,16 @@ export const RouteEditor: React.FC = ({ route, onChange, context }) => { {route.stops.map((stop, i) => ( - - {legRow(legTo(stop.id))} - - updateStop(stop.id, patch)} - onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined} - /> - - + + updateStop(stop.id, patch)} + onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined} + inbound={legFields(legTo(stop.id))} + /> + ))} {!readOnly && ( @@ -112,8 +109,6 @@ export const RouteEditor: React.FC = ({ route, onChange, context }) => { )} - {legRow(returnLeg)} -
{readOnly ? ( @@ -127,6 +122,8 @@ export const RouteEditor: React.FC = ({ route, onChange, context }) => { onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`} /> )} + {/* Обратный переезд — здесь: своей карточки точки у него нет. */} + {legFields(returnLeg)}
diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss deleted file mode 100644 index b28aeb5..0000000 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss +++ /dev/null @@ -1,23 +0,0 @@ -@import '../../../../../../styles/design-tokens'; - -// Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута. -.leg { - display: flex; - flex-direction: column; - gap: $sp-2; - padding-block: $sp-2; -} - -// Время выезда — второстепенно: подпись слева, плашки в строку; на узком экране переносятся. -.time { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: $sp-2; -} - -.timeLabel { - font-family: $font-sans; - font-size: $fs-label; - color: $gray-500; -} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.tsx deleted file mode 100644 index 28cf851..0000000 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; -import styles from './RouteLegRow.module.scss'; -import { FormField, useFormReadOnly } from '../../../../shared/ui'; -import { IRouteLeg, legLabel } from '../../route'; -import { fieldKey } from '../../validation'; -import { TransportChips } from '../TransportChips'; -import { DepartureTimeChips } from '../DepartureTimeChips'; - -interface Props { - leg: IRouteLeg; - transport: string; - onTransportChange: (value: string) => void; - /** Предпочтительное время выезда; '' — не указано. */ - departureTime: string; - onDepartureTimeChange: (value: string) => void; -} - -/** - * Участок пути между точками на линии маршрута: «Алматы → Атырау», вид транспорта - * и предпочтительное время выезда (необязательно). - */ -export const RouteLegRow: React.FC = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => { - const readOnly = useFormReadOnly(); - - return ( -
- - - - {/* В просмотре без указанного времени строка не нужна. */} - {(!readOnly || departureTime) && ( -
- Выезд - -
- )} -
- ); -}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx index 2f5fb9e..6c8d3ac 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx @@ -18,6 +18,8 @@ interface Props { onChange: (patch: Partial) => void; /** Не передан — точку удалить нельзя (она единственная). */ onRemove?: () => void; + /** Переезд в эту точку («Как добраться») — собирает RouteEditor, он знает участки. */ + inbound?: React.ReactNode; } /** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */ @@ -33,10 +35,11 @@ const LOCATIONS: ReadonlyArray> = [ ]; /** - * Точка маршрута: Казахстан или другая страна, город, объект из справочника и даты - * пребывания. В режиме чтения переключатель не нужен — страна видна в полях. + * Точка маршрута: Казахстан или другая страна, город, объект из справочника, даты + * пребывания и переезд сюда (транспорт, время выезда). В режиме чтения переключатель + * не нужен — страна видна в полях. */ -export const RouteStopCard: React.FC = ({ stop, index, context, onChange, onRemove }) => { +export const RouteStopCard: React.FC = ({ stop, index, context, onChange, onRemove, inbound }) => { const readOnly = useFormReadOnly(); const nights = stopNights(stop); @@ -98,6 +101,8 @@ export const RouteStopCard: React.FC = ({ stop, index, context, onChange, /> + {/* После города и дат — тогда в заголовке переезда уже видно, куда едут. */} + {inbound} ); }; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts index 1b2d74b..b5e674c 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts @@ -13,7 +13,7 @@ const ROUTE: ITripRoute = { stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }], returnToOrigin: true, legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }, - legDepartureTime: {} + legDepartureTime: { 'origin>s1': 'Утро', 's1>origin': 'Вечер' } }; const FILLED: IFormData = { @@ -79,6 +79,11 @@ describe('validation: маршрут', () => { expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']); }); + it('у каждого участка нужно время выезда', () => { + const route = { ...ROUTE, legDepartureTime: { 'origin>s1': 'Утро' } }; + expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin-time']); + }); + it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => { expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']); expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]); @@ -99,7 +104,7 @@ describe('validation: маршрут', () => { it('прибытие в следующую точку раньше отъезда из предыдущей', () => { const second = { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', 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': 'Авиа' } }; + const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' }, legDepartureTime: { 'origin>s1': 'Утро', 's1>s2': 'День', 's2>origin': 'Вечер' } }; expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']); }); }); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts index 30a2553..0882af7 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts @@ -36,6 +36,7 @@ export const fieldKey = { stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`, stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`, legTransport: (legId: string): string => `leg-${legId}`, + legDepartureTime: (legId: string): string => `leg-${legId}-time`, articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`, itemLink: (item: IEstimateItem): string => `item-${item.id}-link`, itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`, @@ -105,7 +106,7 @@ function tripLengthIssues(route: ITripRoute): IFieldIssue[] { ]; } -/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */ +/** Маршрут для отправки: город отправления, все точки, вид транспорта и время выезда на каждом участке. */ function routeIssues(route: ITripRoute, today: string): IFieldIssue[] { const issues: IFieldIssue[] = []; if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' }); @@ -113,8 +114,13 @@ function routeIssues(route: ITripRoute, today: string): IFieldIssue[] { issues.push(...stopDateOrderIssues(route)); issues.push(...tripLengthIssues(route)); routeLegs(route).forEach((leg) => { + const label = `Переезд ${legLabel(leg)}`; if (!route.legTransport[leg.id]) { - issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' }); + issues.push({ field: fieldKey.legTransport(leg.id), label, message: 'выберите вид транспорта' }); + } + // Время выезда нужно тревел-координатору под билеты — без него не отправить. + if (!route.legDepartureTime[leg.id]) { + issues.push({ field: fieldKey.legDepartureTime(leg.id), label, message: 'выберите время выезда' }); } }); return issues;