feat: предпочтительное время выезда на участках командировки
У каждого участка маршрута рядом с видом транспорта — «Утро / День / Вечер / Ночь» (необязательно, повторный клик снимает выбор). Хранится в TripRouteJson (legDepartureTime) — новых колонок нет. На схеме — только в подсказке плашки транспорта. Стили плашек вынесены в общий ChoiceChips.module.scss. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,9 @@
|
||||
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
|
||||
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
|
||||
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
|
||||
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
|
||||
(`legDepartureTime`, `DEPARTURE_TIMES`), необязательно — повторный клик снимает выбор. На
|
||||
схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
|
||||
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
|
||||
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
|
||||
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips).
|
||||
|
||||
.chips {
|
||||
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 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';
|
||||
|
||||
@@ -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<Props> = ({ 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<string, string> = {};
|
||||
Object.keys(route.legTransport)
|
||||
.filter((key) => key.split('>').indexOf(id) === -1)
|
||||
.forEach((key) => {
|
||||
legTransport[key] = route.legTransport[key];
|
||||
// Транспорт и время выезда участков, которые шли через удалённую точку, больше ни к чему не относятся.
|
||||
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)
|
||||
});
|
||||
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
|
||||
};
|
||||
const setTransport = (id: string, value: string): void =>
|
||||
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } });
|
||||
const setDepartureTime = (id: string, value: string): void =>
|
||||
onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } });
|
||||
|
||||
const legRow = (leg: IRouteLeg | undefined): React.ReactNode =>
|
||||
leg && (
|
||||
<TimelineRow marker="line">
|
||||
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} />
|
||||
<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>
|
||||
);
|
||||
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({ leg, transport, onTransportChange }) => (
|
||||
/**
|
||||
* Участок пути между точками на линии маршрута: «Алматы → Атырау», вид транспорта
|
||||
* и предпочтительное время выезда (необязательно).
|
||||
*/
|
||||
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<div className={styles.leg}>
|
||||
<FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}>
|
||||
<TransportChips
|
||||
value={transport}
|
||||
onChange={onTransportChange}
|
||||
ariaLabel={`Вид транспорта: ${legLabel(leg)}`}
|
||||
/>
|
||||
<TransportChips value={transport} onChange={onTransportChange} ariaLabel={`Вид транспорта: ${legLabel(leg)}`} />
|
||||
</FormField>
|
||||
{/* В просмотре без указанного времени строка не нужна. */}
|
||||
{(!readOnly || departureTime) && (
|
||||
<div className={styles.time}>
|
||||
<span className={styles.timeLabel}>Выезд</span>
|
||||
<DepartureTimeChips
|
||||
value={departureTime}
|
||||
onChange={onDepartureTimeChange}
|
||||
ariaLabel={`Время выезда: ${legLabel(leg)}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export const RouteScheme: React.FC<Props> = ({ route, articles, onSelectStop })
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
|
||||
legs: IRouteLeg[];
|
||||
legTransport: Record<string, string>;
|
||||
/** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */
|
||||
legDepartureTime: Record<string, string>;
|
||||
onSelectStop?: (stopId: string) => void;
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) =>
|
||||
};
|
||||
|
||||
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
|
||||
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => {
|
||||
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => {
|
||||
const count = nodes.length;
|
||||
return (
|
||||
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
|
||||
@@ -116,7 +118,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
|
||||
</svg>
|
||||
{legs.map((leg, i) => (
|
||||
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
|
||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
|
||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
|
||||
</span>
|
||||
))}
|
||||
</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}>
|
||||
{nodes.map((node, i) => {
|
||||
const leg = legs[i];
|
||||
@@ -151,7 +153,7 @@ const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectSto
|
||||
<div className={styles.vRow}>
|
||||
<span className={styles.vRail} />
|
||||
<span className={styles.vLeg}>
|
||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
|
||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,13 +7,20 @@ import { TransportIcon } from '../TransportIcon';
|
||||
interface Props {
|
||||
leg: IRouteLeg;
|
||||
transport: string;
|
||||
/** Время выезда; '' — не указано. */
|
||||
departureTime: string;
|
||||
}
|
||||
|
||||
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */
|
||||
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => (
|
||||
/**
|
||||
* Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время
|
||||
* выезда — в подсказке (на линию не помещаются).
|
||||
*/
|
||||
export const TransportBadge: React.FC<Props> = ({ leg, transport, departureTime }) => (
|
||||
<span
|
||||
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} />
|
||||
{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' }
|
||||
],
|
||||
returnToOrigin: true,
|
||||
legTransport: {}
|
||||
legTransport: {},
|
||||
legDepartureTime: {}
|
||||
};
|
||||
|
||||
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
||||
|
||||
@@ -65,8 +65,13 @@ export interface ITripRoute {
|
||||
returnToOrigin: boolean;
|
||||
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
|
||||
legTransport: Record<string, string>;
|
||||
/** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */
|
||||
legDepartureTime: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */
|
||||
export const DEPARTURE_TIMES: ReadonlyArray<string> = ['Утро', 'День', 'Вечер', 'Ночь'];
|
||||
|
||||
/** Ключ «точки» города отправления в ключах участков. */
|
||||
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 {
|
||||
|
||||
@@ -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('Атырау');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
const result: Record<string, string> = {};
|
||||
Object.keys(value as object).forEach((key) => {
|
||||
const type = (value as Record<string, unknown>)[key];
|
||||
if (typeof type === 'string') result[key] = type;
|
||||
const entry = (value as Record<string, unknown>)[key];
|
||||
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;
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user