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

Транспорт и время выезда переехали в карточку точки, куда едут («Как
добраться: Алматы → Астана»), обратный переезд — под переключателем
«Возвращение». Отдельной строкой на линии маршрута участок пропускали.
Время выезда теперь обязательно — оно нужно тревел-координатору под билеты.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 16:06:29 +05:00
parent 57feeddcfe
commit ea04602606
11 changed files with 111 additions and 107 deletions
@@ -50,8 +50,11 @@
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д, каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен. такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь» Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
(`legDepartureTime`, `DEPARTURE_TIMES`), необязательно — повторный клик снимает выбор. На (`legDepartureTime`, `DEPARTURE_TIMES`), тоже обязательно — нужно тревел-координатору под
схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали. билеты. На схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
- **Переезд — внутри карточки точки** (с 10-23, `LegFields`): блок «Как добраться: Алматы →
Астана» внизу карточки точки, куда едут; обратный — под переключателем «Возвращение».
Раньше участок был отдельной строкой на линии маршрута — его пропускали при заполнении.
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам - Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение. текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
@@ -12,15 +12,15 @@ interface Props {
} }
/** /**
* Предпочтительное время выезда плашками. Поле необязательное: повторный клик по * Предпочтительное время выезда плашками (обязательно — см. validation.ts).
* выбранной плашке снимает выбор. В режиме чтения — только выбранная плашка * Повторный клик по выбранной плашке снимает выбор. В режиме чтения — только
* (не указано — ничего не показываем). * выбранная плашка.
*/ */
export const DepartureTimeChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => { export const DepartureTimeChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
if (readOnly) { if (readOnly) {
return value ? <span className={`${styles.chip} ${styles.selected} ${styles.static}`}>{value}</span> : null; return <span className={`${styles.chip} ${styles.selected} ${styles.static}`}>{value || 'не указано'}</span>;
} }
return ( return (
@@ -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;
}
@@ -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<Props> = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => (
<div className={styles.leg}>
<span className={styles.title}>Как добраться: {legLabel(leg)}</span>
<FormField label="Вид транспорта" fieldKey={fieldKey.legTransport(leg.id)} required>
<TransportChips value={transport} onChange={onTransportChange} ariaLabel={`Вид транспорта: ${legLabel(leg)}`} />
</FormField>
<FormField label="Время выезда" fieldKey={fieldKey.legDepartureTime(leg.id)} required>
<DepartureTimeChips value={departureTime} onChange={onDepartureTimeChange} ariaLabel={`Время выезда: ${legLabel(leg)}`} />
</FormField>
</div>
);
@@ -108,7 +108,11 @@ $marker-size: 26px;
} }
} }
// Возвращение: переключатель, а под ним — обратный переезд (транспорт, время выезда).
.end { .end {
display: flex;
flex-direction: column;
gap: $sp-3;
padding-top: $sp-2; padding-top: $sp-2;
} }
@@ -7,7 +7,7 @@ import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } 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 { LegFields } from './LegFields';
interface Props { interface Props {
route: ITripRoute; 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<Props> = ({ route, onChange, context }) => { export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
@@ -60,17 +61,15 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
const setDepartureTime = (id: string, value: string): void => const setDepartureTime = (id: string, value: string): void =>
onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } }); onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } });
const legRow = (leg: IRouteLeg | undefined): React.ReactNode => const legFields = (leg: IRouteLeg | undefined): React.ReactNode =>
leg && ( leg && (
<TimelineRow marker="line"> <LegFields
<RouteLegRow leg={leg}
leg={leg} transport={route.legTransport[leg.id] || ''}
transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)}
onTransportChange={(v) => setTransport(leg.id, v)} departureTime={route.legDepartureTime[leg.id] || ''}
departureTime={route.legDepartureTime[leg.id] || ''} onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)}
onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)} />
/>
</TimelineRow>
); );
return ( return (
@@ -89,18 +88,16 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
</TimelineRow> </TimelineRow>
{route.stops.map((stop, i) => ( {route.stops.map((stop, i) => (
<React.Fragment key={stop.id}> <TimelineRow key={stop.id} marker="stop" label={String(i + 1)}>
{legRow(legTo(stop.id))} <RouteStopCard
<TimelineRow marker="stop" label={String(i + 1)}> stop={stop}
<RouteStopCard index={i}
stop={stop} context={context}
index={i} onChange={(patch) => updateStop(stop.id, patch)}
context={context} onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
onChange={(patch) => updateStop(stop.id, patch)} inbound={legFields(legTo(stop.id))}
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined} />
/> </TimelineRow>
</TimelineRow>
</React.Fragment>
))} ))}
{!readOnly && ( {!readOnly && (
@@ -112,8 +109,6 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
</TimelineRow> </TimelineRow>
)} )}
{legRow(returnLeg)}
<TimelineRow marker="endpoint"> <TimelineRow marker="endpoint">
<div className={styles.end}> <div className={styles.end}>
{readOnly ? ( {readOnly ? (
@@ -127,6 +122,8 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`} onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`}
/> />
)} )}
{/* Обратный переезд — здесь: своей карточки точки у него нет. */}
{legFields(returnLeg)}
</div> </div>
</TimelineRow> </TimelineRow>
</div> </div>
@@ -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;
}
@@ -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<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)}`} />
</FormField>
{/* В просмотре без указанного времени строка не нужна. */}
{(!readOnly || departureTime) && (
<div className={styles.time}>
<span className={styles.timeLabel}>Выезд</span>
<DepartureTimeChips
value={departureTime}
onChange={onDepartureTimeChange}
ariaLabel={`Время выезда: ${legLabel(leg)}`}
/>
</div>
)}
</div>
);
};
@@ -18,6 +18,8 @@ interface Props {
onChange: (patch: Partial<IRouteStop>) => void; onChange: (patch: Partial<IRouteStop>) => void;
/** Не передан — точку удалить нельзя (она единственная). */ /** Не передан — точку удалить нельзя (она единственная). */
onRemove?: () => void; onRemove?: () => void;
/** Переезд в эту точку («Как добраться») — собирает RouteEditor, он знает участки. */
inbound?: React.ReactNode;
} }
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */ /** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
@@ -33,10 +35,11 @@ const LOCATIONS: ReadonlyArray<ISegmentedOption<StopLocation>> = [
]; ];
/** /**
* Точка маршрута: Казахстан или другая страна, город, объект из справочника и даты * Точка маршрута: Казахстан или другая страна, город, объект из справочника, даты
* пребывания. В режиме чтения переключатель не нужен — страна видна в полях. * пребывания и переезд сюда (транспорт, время выезда). В режиме чтения переключатель
* не нужен — страна видна в полях.
*/ */
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => { export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove, inbound }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
const nights = stopNights(stop); const nights = stopNights(stop);
@@ -98,6 +101,8 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
/> />
</FormField> </FormField>
</div> </div>
{/* После города и дат — тогда в заголовке переезда уже видно, куда едут. */}
{inbound}
</div> </div>
); );
}; };
@@ -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' }], 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: {} legDepartureTime: { 'origin>s1': 'Утро', 's1>origin': 'Вечер' }
}; };
const FILLED: IFormData = { const FILLED: IFormData = {
@@ -79,6 +79,11 @@ describe('validation: маршрут', () => {
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']); 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('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => { it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => {
expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']); expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']);
expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]); expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]);
@@ -99,7 +104,7 @@ describe('validation: маршрут', () => {
it('прибытие в следующую точку раньше отъезда из предыдущей', () => { it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' }; 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']); expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
}); });
}); });
@@ -36,6 +36,7 @@ export const fieldKey = {
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`, stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`, stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
legTransport: (legId: string): string => `leg-${legId}`, legTransport: (legId: string): string => `leg-${legId}`,
legDepartureTime: (legId: string): string => `leg-${legId}-time`,
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`, articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`, itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`, itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
@@ -105,7 +106,7 @@ function tripLengthIssues(route: ITripRoute): IFieldIssue[] {
]; ];
} }
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */ /** Маршрут для отправки: город отправления, все точки, вид транспорта и время выезда на каждом участке. */
function routeIssues(route: ITripRoute, today: string): IFieldIssue[] { function routeIssues(route: ITripRoute, today: string): IFieldIssue[] {
const issues: IFieldIssue[] = []; const issues: IFieldIssue[] = [];
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' }); 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(...stopDateOrderIssues(route));
issues.push(...tripLengthIssues(route)); issues.push(...tripLengthIssues(route));
routeLegs(route).forEach((leg) => { routeLegs(route).forEach((leg) => {
const label = `Переезд ${legLabel(leg)}`;
if (!route.legTransport[leg.id]) { 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; return issues;