feat: предпочтительное время выезда на участках командировки

У каждого участка маршрута рядом с видом транспорта — «Утро / День / Вечер /
Ночь» (необязательно, повторный клик снимает выбор). Хранится в TripRouteJson
(legDepartureTime) — новых колонок нет. На схеме — только в подсказке плашки
транспорта. Стили плашек вынесены в общий ChoiceChips.module.scss.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 15:21:13 +05:00
parent 63448e60aa
commit 51f3c6fb9a
15 changed files with 172 additions and 43 deletions
@@ -49,6 +49,9 @@
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у - Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д, каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен. такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
(`legDepartureTime`, `DEPARTURE_TIMES`), необязательно — повторный клик снимает выбор. На
схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам - Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение. текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
@@ -1,4 +1,5 @@
@import '../../../../../styles/design-tokens'; @import '../../../../../styles/design-tokens';
// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips).
.chips { .chips {
display: flex; display: flex;
@@ -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<Props> = ({ value, onChange, ariaLabel }) => {
const readOnly = useFormReadOnly();
if (readOnly) {
return value ? <span className={`${styles.chip} ${styles.selected} ${styles.static}`}>{value}</span> : null;
}
return (
<div className={styles.chips} role="radiogroup" aria-label={ariaLabel}>
{DEPARTURE_TIMES.map((time) => {
const selected = time === value;
return (
<button
key={time}
type="button"
role="radio"
aria-checked={selected}
className={selected ? `${styles.chip} ${styles.selected}` : styles.chip}
onClick={() => onChange(selected ? '' : time)}
>
{time}
</button>
);
})}
</div>
);
};
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import styles from './TransportChips.module.scss'; import styles from './ChoiceChips.module.scss';
import { useFormReadOnly } from '../../../shared/ui'; import { useFormReadOnly } from '../../../shared/ui';
import { TRANSPORT_TYPES } from '../model'; import { TRANSPORT_TYPES } from '../model';
import { TransportIcon } from './TransportIcon'; import { TransportIcon } from './TransportIcon';
@@ -4,7 +4,7 @@ import styles from './RouteEditor.module.scss';
import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui'; import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary'; import { CityPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model'; 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 { fieldKey } from '../../validation';
import { RouteStopCard } from './RouteStopCard'; import { RouteStopCard } from './RouteStopCard';
import { RouteLegRow } from './RouteLegRow'; import { RouteLegRow } from './RouteLegRow';
@@ -47,23 +47,29 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) }); onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const addStop = (): void => const addStop = (): void =>
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] }); onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
const removeStop = (id: string): void => { // Транспорт и время выезда участков, которые шли через удалённую точку, больше ни к чему не относятся.
// Виды транспорта участков, которые шли через удалённую точку, больше ни к чему не относятся. const removeStop = (id: string): void =>
const legTransport: Record<string, string> = {}; onChange({
Object.keys(route.legTransport) ...route,
.filter((key) => key.split('>').indexOf(id) === -1) stops: route.stops.filter((s) => s.id !== id),
.forEach((key) => { legTransport: withoutStopLegs(route.legTransport, id),
legTransport[key] = route.legTransport[key]; legDepartureTime: withoutStopLegs(route.legDepartureTime, id)
}); });
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
};
const setTransport = (id: string, value: string): void => const setTransport = (id: string, value: string): void =>
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } }); 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 => const legRow = (leg: IRouteLeg | undefined): React.ReactNode =>
leg && ( leg && (
<TimelineRow marker="line"> <TimelineRow marker="line">
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} /> <RouteLegRow
leg={leg}
transport={route.legTransport[leg.id] || ''}
onTransportChange={(v) => setTransport(leg.id, v)}
departureTime={route.legDepartureTime[leg.id] || ''}
onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)}
/>
</TimelineRow> </TimelineRow>
); );
@@ -2,5 +2,22 @@
// Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута. // Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута.
.leg { .leg {
display: flex;
flex-direction: column;
gap: $sp-2;
padding-block: $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;
}
@@ -1,25 +1,43 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RouteLegRow.module.scss'; import styles from './RouteLegRow.module.scss';
import { FormField } from '../../../../shared/ui'; import { FormField, useFormReadOnly } from '../../../../shared/ui';
import { IRouteLeg, legLabel } from '../../route'; import { IRouteLeg, legLabel } from '../../route';
import { fieldKey } from '../../validation'; import { fieldKey } from '../../validation';
import { TransportChips } from '../TransportChips'; import { TransportChips } from '../TransportChips';
import { DepartureTimeChips } from '../DepartureTimeChips';
interface Props { interface Props {
leg: IRouteLeg; leg: IRouteLeg;
transport: string; transport: string;
onTransportChange: (value: string) => void; onTransportChange: (value: string) => void;
/** Предпочтительное время выезда; '' — не указано. */
departureTime: string;
onDepartureTimeChange: (value: string) => void;
} }
/** Участок пути между точками на линии маршрута: «Алматы → Атырау» и вид транспорта. */ /**
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange }) => ( * Участок пути между точками на линии маршрута: «Алматы → Атырау», вид транспорта
* и предпочтительное время выезда (необязательно).
*/
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => {
const readOnly = useFormReadOnly();
return (
<div className={styles.leg}> <div className={styles.leg}>
<FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}> <FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}>
<TransportChips <TransportChips value={transport} onChange={onTransportChange} ariaLabel={`Вид транспорта: ${legLabel(leg)}`} />
value={transport}
onChange={onTransportChange}
ariaLabel={`Вид транспорта: ${legLabel(leg)}`}
/>
</FormField> </FormField>
{/* В просмотре без указанного времени строка не нужна. */}
{(!readOnly || departureTime) && (
<div className={styles.time}>
<span className={styles.timeLabel}>Выезд</span>
<DepartureTimeChips
value={departureTime}
onChange={onDepartureTimeChange}
ariaLabel={`Время выезда: ${legLabel(leg)}`}
/>
</div> </div>
); )}
</div>
);
};
@@ -56,7 +56,7 @@ export const RouteScheme: React.FC<Props> = ({ route, articles, onSelectStop })
return ( return (
<div className={styles.scheme}> <div className={styles.scheme}>
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} onSelectStop={onSelectStop} /> <SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} legDepartureTime={route.legDepartureTime} onSelectStop={onSelectStop} />
<div className={styles.stats}>{stats.join(' · ')}</div> <div className={styles.stats}>{stats.join(' · ')}</div>
</div> </div>
); );
@@ -10,6 +10,8 @@ interface Props {
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */ /** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
legs: IRouteLeg[]; legs: IRouteLeg[];
legTransport: Record<string, string>; legTransport: Record<string, string>;
/** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */
legDepartureTime: Record<string, string>;
onSelectStop?: (stopId: string) => void; onSelectStop?: (stopId: string) => void;
} }
@@ -77,7 +79,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) =>
}; };
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */ /** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => { const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => {
const count = nodes.length; const count = nodes.length;
return ( return (
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}> <div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
@@ -116,7 +118,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
</svg> </svg>
{legs.map((leg, i) => ( {legs.map((leg, i) => (
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}> <span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} /> <TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
</span> </span>
))} ))}
</div> </div>
@@ -130,7 +132,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
}; };
/** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */ /** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => ( const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => (
<div className={styles.vTrack}> <div className={styles.vTrack}>
{nodes.map((node, i) => { {nodes.map((node, i) => {
const leg = legs[i]; const leg = legs[i];
@@ -151,7 +153,7 @@ const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectSto
<div className={styles.vRow}> <div className={styles.vRow}>
<span className={styles.vRail} /> <span className={styles.vRail} />
<span className={styles.vLeg}> <span className={styles.vLeg}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} /> <TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
</span> </span>
</div> </div>
)} )}
@@ -7,13 +7,20 @@ import { TransportIcon } from '../TransportIcon';
interface Props { interface Props {
leg: IRouteLeg; leg: IRouteLeg;
transport: string; transport: string;
/** Время выезда; '' — не указано. */
departureTime: string;
} }
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */ /**
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => ( * Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время
* выезда — в подсказке (на линию не помещаются).
*/
export const TransportBadge: React.FC<Props> = ({ leg, transport, departureTime }) => (
<span <span
className={transport ? styles.transport : `${styles.transport} ${styles.missing}`} className={transport ? styles.transport : `${styles.transport} ${styles.missing}`}
title={`${leg.from} → ${leg.to}${transport ? `: ${transport}` : ''}`} title={[`${leg.from} → ${leg.to}`, transport, departureTime && `выезд: ${departureTime.toLowerCase()}`]
.filter(Boolean)
.join(', ')}
> >
<TransportIcon type={transport} className={styles.transportIcon} /> <TransportIcon type={transport} className={styles.transportIcon} />
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'} {transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
@@ -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' } { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
], ],
returnToOrigin: true, returnToOrigin: true,
legTransport: {} legTransport: {},
legDepartureTime: {}
}; };
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({ const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
@@ -65,8 +65,13 @@ export interface ITripRoute {
returnToOrigin: boolean; returnToOrigin: boolean;
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */ /** Вид транспорта по ключу участка (ключ — legId из route.ts). */
legTransport: Record<string, string>; legTransport: Record<string, string>;
/** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */
legDepartureTime: Record<string, string>;
} }
/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */
export const DEPARTURE_TIMES: ReadonlyArray<string> = ['Утро', 'День', 'Вечер', 'Ночь'];
/** Ключ «точки» города отправления в ключах участков. */ /** Ключ «точки» города отправления в ключах участков. */
export const ORIGIN_ID = 'origin'; 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: '' } { id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }
], ],
returnToOrigin: true, returnToOrigin: true,
legTransport: {} legTransport: {},
legDepartureTime: {}
}; };
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem { function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
@@ -11,7 +11,8 @@ import {
serializeRoute, serializeRoute,
stopLocationPatch, stopLocationPatch,
stopNights, stopNights,
tripKind tripKind,
withoutStopLegs
} from './route'; } from './route';
const ROUTE: ITripRoute = { 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' } { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
], ],
returnToOrigin: true, returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа' } legTransport: { 'origin>s1': 'Авиа' },
legDepartureTime: {}
}; };
describe('участки пути', () => { describe('участки пути', () => {
@@ -79,6 +81,11 @@ describe('сводка маршрута', () => {
expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']); 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('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => { it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => {
expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' }); expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' });
expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' }); expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' });
@@ -95,6 +102,7 @@ describe('хранение маршрута', () => {
const route = parseRoute(JSON.stringify(old)); const route = parseRoute(JSON.stringify(old));
expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']); 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.stops[0].abroad, route.stops[0].country]).toEqual([false, '']);
expect(route.legDepartureTime).toEqual({});
expect(routeCities(route)).toBe('Атырау'); expect(routeCities(route)).toBe('Атырау');
}); });
@@ -192,12 +192,24 @@ function toStop(value: unknown): IRouteStop | undefined {
}; };
} }
function toTransport(value: unknown): Record<string, string> { /** Словарь «ключ участка → значение» (вид транспорта, время выезда); не строки отбрасываются. */
function toLegRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {}; if (!value || typeof value !== 'object') return {};
const result: Record<string, string> = {}; const result: Record<string, string> = {};
Object.keys(value as object).forEach((key) => { Object.keys(value as object).forEach((key) => {
const type = (value as Record<string, unknown>)[key]; const entry = (value as Record<string, unknown>)[key];
if (typeof type === 'string') result[key] = type; if (typeof entry === 'string') result[key] = entry;
});
return result;
}
/** Убрать из словаря участков те, что шли через точку stopId (её удалили из маршрута). */
export function withoutStopLegs(record: Record<string, string>, stopId: string): Record<string, string> {
const result: Record<string, string> = {};
Object.keys(record)
.filter((key) => key.split('>').indexOf(stopId) === -1)
.forEach((key) => {
result[key] = record[key];
}); });
return result; return result;
} }
@@ -222,6 +234,8 @@ export function parseRoute(raw: unknown): ITripRoute {
originKato: text(rawRoute.originKato), originKato: text(rawRoute.originKato),
stops, stops,
returnToOrigin: rawRoute.returnToOrigin !== false, returnToOrigin: rawRoute.returnToOrigin !== false,
legTransport: toTransport(rawRoute.legTransport) legTransport: toLegRecord(rawRoute.legTransport),
// Заявки до времени выезда — без этого поля: время просто не указано.
legDepartureTime: toLegRecord(rawRoute.legDepartureTime)
}; };
} }
@@ -12,7 +12,8 @@ const ROUTE: ITripRoute = {
originKato: '', originKato: '',
stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }], stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
returnToOrigin: true, returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' } legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' },
legDepartureTime: {}
}; };
const FILLED: IFormData = { const FILLED: IFormData = {