From 51f3c6fb9a902b32783083d311196d54b28075b2 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: Fri, 23 Oct 2026 15:21:13 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D1=82=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B2=D1=8B=D0=B5=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У каждого участка маршрута рядом с видом транспорта — «Утро / День / Вечер / Ночь» (необязательно, повторный клик снимает выбор). Хранится в TripRouteJson (legDepartureTime) — новых колонок нет. На схеме — только в подсказке плашки транспорта. Стили плашек вынесены в общий ChoiceChips.module.scss. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/BusinessTrip/README.md | 3 ++ ...ps.module.scss => ChoiceChips.module.scss} | 1 + .../components/DepartureTimeChips.tsx | 45 +++++++++++++++++++ .../components/TransportChips.tsx | 2 +- .../components/route/RouteEditor.tsx | 30 ++++++++----- .../components/route/RouteLegRow.module.scss | 17 +++++++ .../components/route/RouteLegRow.tsx | 44 ++++++++++++------ .../components/route/RouteScheme.tsx | 2 +- .../components/route/SchemeTrack.tsx | 10 +++-- .../components/route/TransportBadge.tsx | 13 ++++-- .../forms/BusinessTrip/estimate.test.ts | 3 +- .../requestPortal/forms/BusinessTrip/model.ts | 8 +++- .../forms/BusinessTrip/route.test.ts | 12 ++++- .../requestPortal/forms/BusinessTrip/route.ts | 22 +++++++-- .../forms/BusinessTrip/validation.test.ts | 3 +- 15 files changed, 172 insertions(+), 43 deletions(-) rename src/webparts/requestPortal/forms/BusinessTrip/components/{TransportChips.module.scss => ChoiceChips.module.scss} (86%) create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index 232509b..088bc40 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -49,6 +49,9 @@ - Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д, такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен. + Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь» + (`legDepartureTime`, `DEPARTURE_TIMES`), необязательно — повторный клик снимает выбор. На + схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали. - Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/ChoiceChips.module.scss similarity index 86% rename from src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss rename to src/webparts/requestPortal/forms/BusinessTrip/components/ChoiceChips.module.scss index 7acf414..0dcb3a9 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.module.scss +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/ChoiceChips.module.scss @@ -1,4 +1,5 @@ @import '../../../../../styles/design-tokens'; +// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips). .chips { display: flex; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx new file mode 100644 index 0000000..98f3e1c --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/DepartureTimeChips.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import styles from './ChoiceChips.module.scss'; +import { useFormReadOnly } from '../../../shared/ui'; +import { DEPARTURE_TIMES } from '../model'; + +interface Props { + /** Значение из DEPARTURE_TIMES; '' — не указано. */ + value: string; + onChange: (value: string) => void; + /** Подпись группы для экранных дикторов, напр. «Время выезда: Алматы → Атырау». */ + ariaLabel: string; +} + +/** + * Предпочтительное время выезда плашками. Поле необязательное: повторный клик по + * выбранной плашке снимает выбор. В режиме чтения — только выбранная плашка + * (не указано — ничего не показываем). + */ +export const DepartureTimeChips: React.FC = ({ value, onChange, ariaLabel }) => { + const readOnly = useFormReadOnly(); + + if (readOnly) { + return value ? {value} : null; + } + + return ( +
+ {DEPARTURE_TIMES.map((time) => { + const selected = time === value; + return ( + + ); + })} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx index e8bf61b..dcae644 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/TransportChips.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import styles from './TransportChips.module.scss'; +import styles from './ChoiceChips.module.scss'; import { useFormReadOnly } from '../../../shared/ui'; import { TRANSPORT_TYPES } from '../model'; import { TransportIcon } from './TransportIcon'; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx index 12be714..0a614f2 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteEditor.tsx @@ -4,7 +4,7 @@ import styles from './RouteEditor.module.scss'; import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui'; import { CityPicker } from '../../../../shared/dictionary'; import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model'; -import { IRouteLeg, createStop, originLabel, routeLegs } from '../../route'; +import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } from '../../route'; import { fieldKey } from '../../validation'; import { RouteStopCard } from './RouteStopCard'; import { RouteLegRow } from './RouteLegRow'; @@ -47,23 +47,29 @@ export const RouteEditor: React.FC = ({ route, onChange, context }) => { 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 = {}; - 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 removeStop = (id: string): void => + onChange({ + ...route, + stops: route.stops.filter((s) => s.id !== id), + legTransport: withoutStopLegs(route.legTransport, id), + legDepartureTime: withoutStopLegs(route.legDepartureTime, id) + }); const setTransport = (id: string, value: string): void => onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } }); + const setDepartureTime = (id: string, value: string): void => + onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } }); const legRow = (leg: IRouteLeg | undefined): React.ReactNode => leg && ( - setTransport(leg.id, v)} /> + setTransport(leg.id, v)} + departureTime={route.legDepartureTime[leg.id] || ''} + onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)} + /> ); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss index 9881c8a..b28aeb5 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.module.scss @@ -2,5 +2,22 @@ // Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута. .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 index 169f930..28cf851 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteLegRow.tsx @@ -1,25 +1,43 @@ import * as React from 'react'; import styles from './RouteLegRow.module.scss'; -import { FormField } from '../../../../shared/ui'; +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 }) => ( -
- - - -
-); +/** + * Участок пути между точками на линии маршрута: «Алматы → Атырау», вид транспорта + * и предпочтительное время выезда (необязательно). + */ +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/RouteScheme.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteScheme.tsx index 80510d2..89ba3c5 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteScheme.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteScheme.tsx @@ -56,7 +56,7 @@ export const RouteScheme: React.FC = ({ route, articles, onSelectStop }) return (
- +
{stats.join(' · ')}
); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/SchemeTrack.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/SchemeTrack.tsx index 4b2657a..33706e3 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/SchemeTrack.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/SchemeTrack.tsx @@ -10,6 +10,8 @@ interface Props { /** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */ legs: IRouteLeg[]; legTransport: Record; + /** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */ + legDepartureTime: Record; onSelectStop?: (stopId: string) => void; } @@ -77,7 +79,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) => }; /** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */ -const HorizontalTrack: React.FC = ({ nodes, legs, legTransport, onSelectStop }) => { +const HorizontalTrack: React.FC = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => { const count = nodes.length; return (
@@ -116,7 +118,7 @@ const HorizontalTrack: React.FC = ({ nodes, legs, legTransport, onSelectS {legs.map((leg, i) => ( - + ))}
@@ -130,7 +132,7 @@ const HorizontalTrack: React.FC = ({ nodes, legs, legTransport, onSelectS }; /** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */ -const VerticalTrack: React.FC = ({ nodes, legs, legTransport, onSelectStop }) => ( +const VerticalTrack: React.FC = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => (
{nodes.map((node, i) => { const leg = legs[i]; @@ -151,7 +153,7 @@ const VerticalTrack: React.FC = ({ nodes, legs, legTransport, onSelectSto
- +
)} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/TransportBadge.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/TransportBadge.tsx index 0b3a51b..c82dade 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/TransportBadge.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/TransportBadge.tsx @@ -7,13 +7,20 @@ import { TransportIcon } from '../TransportIcon'; interface Props { leg: IRouteLeg; transport: string; + /** Время выезда; '' — не указано. */ + departureTime: string; } -/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */ -export const TransportBadge: React.FC = ({ leg, transport }) => ( +/** + * Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время + * выезда — в подсказке (на линию не помещаются). + */ +export const TransportBadge: React.FC = ({ leg, transport, departureTime }) => ( {transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts index 987f318..4ffd0c4 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts @@ -35,7 +35,8 @@ const ROUTE: ITripRoute = { { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' } ], returnToOrigin: true, - legTransport: {} + legTransport: {}, + legDepartureTime: {} }; const item = (patch: Partial): IEstimateItem => ({ diff --git a/src/webparts/requestPortal/forms/BusinessTrip/model.ts b/src/webparts/requestPortal/forms/BusinessTrip/model.ts index 15efb74..6d4b730 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/model.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/model.ts @@ -65,8 +65,13 @@ export interface ITripRoute { returnToOrigin: boolean; /** Вид транспорта по ключу участка (ключ — legId из route.ts). */ legTransport: Record; + /** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */ + legDepartureTime: Record; } +/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */ +export const DEPARTURE_TIMES: ReadonlyArray = ['Утро', 'День', 'Вечер', 'Ночь']; + /** Ключ «точки» города отправления в ключах участков. */ export const ORIGIN_ID = 'origin'; @@ -357,7 +362,8 @@ export const EMPTY_ROUTE: ITripRoute = { { id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' } ], returnToOrigin: true, - legTransport: {} + legTransport: {}, + legDepartureTime: {} }; function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem { diff --git a/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts index c47572c..4a3eea7 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts @@ -11,7 +11,8 @@ import { serializeRoute, stopLocationPatch, stopNights, - tripKind + tripKind, + withoutStopLegs } from './route'; const ROUTE: ITripRoute = { @@ -23,7 +24,8 @@ const ROUTE: ITripRoute = { { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' } ], returnToOrigin: true, - legTransport: { 'origin>s1': 'Авиа' } + legTransport: { 'origin>s1': 'Авиа' }, + legDepartureTime: {} }; describe('участки пути', () => { @@ -79,6 +81,11 @@ describe('сводка маршрута', () => { expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']); }); + it('удалили точку — транспорт и время выезда её участков уходят', () => { + const times = { 'origin>s1': 'Утро', 's1>s2': 'Вечер', 's2>origin': 'Ночь' }; + expect(withoutStopLegs(times, 's2')).toEqual({ 'origin>s1': 'Утро' }); + }); + it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => { expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' }); expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' }); @@ -95,6 +102,7 @@ describe('хранение маршрута', () => { const route = parseRoute(JSON.stringify(old)); expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']); expect([route.stops[0].abroad, route.stops[0].country]).toEqual([false, '']); + expect(route.legDepartureTime).toEqual({}); expect(routeCities(route)).toBe('Атырау'); }); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/route.ts b/src/webparts/requestPortal/forms/BusinessTrip/route.ts index 1cde636..5a981e9 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/route.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/route.ts @@ -192,16 +192,28 @@ function toStop(value: unknown): IRouteStop | undefined { }; } -function toTransport(value: unknown): Record { +/** Словарь «ключ участка → значение» (вид транспорта, время выезда); не строки отбрасываются. */ +function toLegRecord(value: unknown): Record { if (!value || typeof value !== 'object') return {}; const result: Record = {}; Object.keys(value as object).forEach((key) => { - const type = (value as Record)[key]; - if (typeof type === 'string') result[key] = type; + const entry = (value as Record)[key]; + if (typeof entry === 'string') result[key] = entry; }); return result; } +/** Убрать из словаря участков те, что шли через точку stopId (её удалили из маршрута). */ +export function withoutStopLegs(record: Record, stopId: string): Record { + const result: Record = {}; + Object.keys(record) + .filter((key) => key.split('>').indexOf(stopId) === -1) + .forEach((key) => { + result[key] = record[key]; + }); + return result; +} + /** Разобрать TripRouteJson. Битый JSON или маршрут без точек — маршрут новой заявки. */ export function parseRoute(raw: unknown): ITripRoute { let parsed: unknown; @@ -222,6 +234,8 @@ export function parseRoute(raw: unknown): ITripRoute { originKato: text(rawRoute.originKato), stops, returnToOrigin: rawRoute.returnToOrigin !== false, - legTransport: toTransport(rawRoute.legTransport) + legTransport: toLegRecord(rawRoute.legTransport), + // Заявки до времени выезда — без этого поля: время просто не указано. + legDepartureTime: toLegRecord(rawRoute.legDepartureTime) }; } diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts index c3bb1cd..1b2d74b 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts @@ -12,7 +12,8 @@ const ROUTE: ITripRoute = { originKato: '', 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': 'Авиа' } + legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }, + legDepartureTime: {} }; const FILLED: IFormData = {