feat: точки командировки по Казахстану и за рубежом
У каждой точки маршрута — «Казахстан | Другая страна» (бывают смешанные поездки). За рубежом страна и город вводятся вручную, подпись «Китай, Пекин», объект можно вписать своим текстом (завод, выставка — их нет в справочнике). Новая колонка TripKind («По Казахстану» / «Зарубежная») — хотя бы одна точка за рубежом делает поездку зарубежной; пригодится для маршрута и отчётов. Переключатель «Форма | Схема» вынесен в общий SegmentedSwitch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,10 +38,20 @@
|
||||
всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том
|
||||
числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется
|
||||
введённый текст без казахского названия.
|
||||
- **Казахстан / другая страна — у каждой точки** (с 10-23, `SegmentedSwitch` в карточке точки):
|
||||
признак у точки, а не у поездки — бывают смешанные маршруты (Алматы → Москва → Атырау).
|
||||
За рубежом — «Страна» и «Город» вручную (`abroad`/`country` в `TripRouteJson`), подпись
|
||||
«Китай, Пекин» (страна первой). Смена признака: город остаётся, за рубежом сбрасываются казахское название
|
||||
и код КАТО, у точки по Казахстану — страна (`stopLocationPatch`). Новая точка — в той же
|
||||
стране, что предыдущая. Город отправления — всегда из справочника (можно и свой текст).
|
||||
Колонка `TripKind` («По Казахстану» / «Зарубежная»): хотя бы одна точка за рубежом —
|
||||
зарубежная; пригодится для маршрута согласования и отчётов. Старые заявки — по Казахстану.
|
||||
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
|
||||
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
|
||||
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
|
||||
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
|
||||
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
|
||||
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
|
||||
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
|
||||
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
|
||||
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
|
||||
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете.
|
||||
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете (AmountFields).
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SegmentedSwitch.module.scss';
|
||||
|
||||
export interface ISegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props<T extends string> {
|
||||
value: T;
|
||||
options: ReadonlyArray<ISegmentedOption<T>>;
|
||||
onChange: (value: T) => void;
|
||||
/** Подпись группы для экранных дикторов. */
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Сегментный переключатель из 2–3 вариантов: «Форма | Схема», «Казахстан | Другая страна». */
|
||||
export function SegmentedSwitch<T extends string>({ value, options, onChange, ariaLabel, className }: Props<T>): JSX.Element {
|
||||
return (
|
||||
<div className={className ? `${styles.switch} ${className}` : styles.switch} role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === option.value}
|
||||
className={value === option.value ? `${styles.option} ${styles.active}` : styles.option}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './RouteStopCard.module.scss';
|
||||
import { FormField, Icon, TextInput } from '../../../../shared/ui';
|
||||
import { CityPicker, ProjectPicker } from '../../../../shared/dictionary';
|
||||
import { CITY_PLACEHOLDER, IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
|
||||
import { stopNights } from '../../route';
|
||||
import { FormField, Icon, TextInput, useFormReadOnly } from '../../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../../shared/dictionary';
|
||||
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
|
||||
import { stopLocationPatch, stopNights } from '../../route';
|
||||
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
|
||||
import { StopPlaceFields } from './StopPlaceFields';
|
||||
import { plural } from '../../tripData';
|
||||
import { todaySubmitDate } from '../../../../shared/submitDate';
|
||||
import { fieldKey } from '../../validation';
|
||||
@@ -23,8 +25,19 @@ export function stopCardDomId(stopId: string): string {
|
||||
return `route-stop-${stopId}`;
|
||||
}
|
||||
|
||||
/** Точка маршрута: город, объект из справочника и даты пребывания. */
|
||||
type StopLocation = 'domestic' | 'abroad';
|
||||
|
||||
const LOCATIONS: ReadonlyArray<ISegmentedOption<StopLocation>> = [
|
||||
{ value: 'domestic', label: 'Казахстан' },
|
||||
{ value: 'abroad', label: 'Другая страна' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Точка маршрута: Казахстан или другая страна, город, объект из справочника и даты
|
||||
* пребывания. В режиме чтения переключатель не нужен — страна видна в полях.
|
||||
*/
|
||||
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const nights = stopNights(stop);
|
||||
|
||||
return (
|
||||
@@ -40,16 +53,23 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
|
||||
<CityPicker
|
||||
id={fieldKey.stopCity(stop)}
|
||||
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }}
|
||||
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })}
|
||||
placeholder={CITY_PLACEHOLDER}
|
||||
{!readOnly && (
|
||||
<SegmentedSwitch
|
||||
value={stop.abroad ? 'abroad' : 'domestic'}
|
||||
options={LOCATIONS}
|
||||
onChange={(location) => onChange(stopLocationPatch(location === 'abroad'))}
|
||||
ariaLabel={`Где точка ${index + 1}`}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
|
||||
)}
|
||||
<div className={styles.grid}>
|
||||
<StopPlaceFields stop={stop} onChange={onChange} />
|
||||
{/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */}
|
||||
<FormField
|
||||
label={stop.abroad ? 'Объект / место назначения' : 'Объект'}
|
||||
htmlFor={fieldKey.stopProject(stop)}
|
||||
required
|
||||
full={stop.abroad}
|
||||
>
|
||||
<ProjectPicker
|
||||
id={fieldKey.stopProject(stop)}
|
||||
context={context}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RouteViewSwitch.module.scss';
|
||||
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
|
||||
|
||||
export type RouteViewMode = 'form' | 'scheme';
|
||||
|
||||
@@ -8,25 +8,12 @@ interface Props {
|
||||
onChange: (mode: RouteViewMode) => void;
|
||||
}
|
||||
|
||||
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [
|
||||
{ mode: 'form', label: 'Форма' },
|
||||
{ mode: 'scheme', label: 'Схема' }
|
||||
const MODES: ReadonlyArray<ISegmentedOption<RouteViewMode>> = [
|
||||
{ value: 'form', label: 'Форма' },
|
||||
{ value: 'scheme', label: 'Схема' }
|
||||
];
|
||||
|
||||
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */
|
||||
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */
|
||||
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
|
||||
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута">
|
||||
{MODES.map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === mode}
|
||||
className={value === mode ? `${styles.option} ${styles.active}` : styles.option}
|
||||
onClick={() => onChange(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedSwitch value={value} options={MODES} onChange={onChange} ariaLabel="Вид маршрута" className="rp-no-print" />
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { FormField, TextInput } from '../../../../shared/ui';
|
||||
import { CityPicker } from '../../../../shared/dictionary';
|
||||
import { ABROAD_CITY_PLACEHOLDER, CITY_PLACEHOLDER, COUNTRY_PLACEHOLDER, IRouteStop } from '../../model';
|
||||
import { fieldKey } from '../../validation';
|
||||
|
||||
interface Props {
|
||||
stop: IRouteStop;
|
||||
onChange: (patch: Partial<IRouteStop>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Где находится точка. По Казахстану — город из справочника КАТО (оба языка + код);
|
||||
* за рубежом — страна и город вручную: справочник только по РК.
|
||||
* Рендерит ячейки сетки карточки точки (без своей обёртки).
|
||||
*/
|
||||
export const StopPlaceFields: React.FC<Props> = ({ stop, onChange }) => {
|
||||
if (!stop.abroad) {
|
||||
return (
|
||||
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
|
||||
<CityPicker
|
||||
id={fieldKey.stopCity(stop)}
|
||||
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }}
|
||||
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })}
|
||||
placeholder={CITY_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Страна" htmlFor={fieldKey.stopCountry(stop)} required>
|
||||
<TextInput
|
||||
id={fieldKey.stopCountry(stop)}
|
||||
value={stop.country}
|
||||
onChange={(v) => onChange({ country: v })}
|
||||
placeholder={COUNTRY_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Город" htmlFor={fieldKey.stopCity(stop)} required>
|
||||
<TextInput
|
||||
id={fieldKey.stopCity(stop)}
|
||||
value={stop.city}
|
||||
onChange={(v) => onChange({ city: v })}
|
||||
placeholder={ABROAD_CITY_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -20,8 +20,8 @@ const ROUTE: ITripRoute = {
|
||||
originKz: '',
|
||||
originKato: '',
|
||||
stops: [
|
||||
{ id: 's1', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||
{ id: 's2', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||
],
|
||||
returnToOrigin: true,
|
||||
legTransport: {}
|
||||
|
||||
@@ -20,6 +20,14 @@ export const FORM_TITLE = 'Заявка на командировку';
|
||||
export interface IRouteStop {
|
||||
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
|
||||
id: string;
|
||||
/**
|
||||
* true — точка за рубежом: страна и город вводятся вручную (справочник КАТО только по РК),
|
||||
* у суточных и проживания свои лимиты. Признак у точки, а не у поездки: бывают смешанные
|
||||
* маршруты (Алматы → Москва → Атырау).
|
||||
*/
|
||||
abroad: boolean;
|
||||
/** Страна — только у зарубежной точки; у точки по Казахстану ''. */
|
||||
country: string;
|
||||
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
|
||||
city: string;
|
||||
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
|
||||
@@ -28,7 +36,10 @@ export interface IRouteStop {
|
||||
cityKato: string;
|
||||
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
|
||||
project: string;
|
||||
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
|
||||
/**
|
||||
* oid проекта; '' — текст введён вручную без выбора из списка. По Казахстану отправка
|
||||
* такое не пропустит; за рубежом можно — там объект часто чужой (завод, выставка).
|
||||
*/
|
||||
projectId: string;
|
||||
/** YYYY-MM-DD — прибытие в точку. */
|
||||
dateFrom: string;
|
||||
@@ -59,6 +70,16 @@ export interface ITripRoute {
|
||||
/** Ключ «точки» города отправления в ключах участков. */
|
||||
export const ORIGIN_ID = 'origin';
|
||||
|
||||
/**
|
||||
* Вид поездки — значения Choice-колонки TripKind. Выводится из точек: хотя бы одна
|
||||
* за рубежом — вся поездка зарубежная (по ней будут маршрут согласования и отчёты).
|
||||
*/
|
||||
export const TRIP_KIND = {
|
||||
domestic: 'По Казахстану',
|
||||
abroad: 'Зарубежная'
|
||||
} as const;
|
||||
export type TripKind = (typeof TRIP_KIND)[keyof typeof TRIP_KIND];
|
||||
|
||||
/**
|
||||
* Вид транспорта участка. Предположение: в бланке список не задан — варианты
|
||||
* взяты типовые; поправить здесь, если кадры используют другие.
|
||||
@@ -314,7 +335,9 @@ export const EMPTY_ROUTE: ITripRoute = {
|
||||
origin: '',
|
||||
originKz: '',
|
||||
originKato: '',
|
||||
stops: [{ id: FIRST_STOP_ID, city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
|
||||
stops: [
|
||||
{ id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }
|
||||
],
|
||||
returnToOrigin: true,
|
||||
legTransport: {}
|
||||
};
|
||||
@@ -371,5 +394,7 @@ export const OBLIGATION_TEXT =
|
||||
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
|
||||
|
||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||
export const COUNTRY_PLACEHOLDER = 'Напр.: Китай';
|
||||
export const ABROAD_CITY_PLACEHOLDER = 'Напр.: Пекин';
|
||||
export const CITY_PLACEHOLDER ='Начните вводить город: Атырау, Құлсары…';
|
||||
export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
|
||||
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
|
||||
|
||||
{ "internalName": "TripKind", "title": "Вид командировки", "type": "Choice", "choices": ["По Казахстану", "Зарубежная"] },
|
||||
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
|
||||
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
|
||||
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EMPTY_ROUTE, ITripRoute } from './model';
|
||||
import { EMPTY_ROUTE, ITripRoute, TRIP_KIND } from './model';
|
||||
import {
|
||||
createStop,
|
||||
legLabel,
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
routeProjects,
|
||||
routeText,
|
||||
serializeRoute,
|
||||
stopNights
|
||||
stopLocationPatch,
|
||||
stopNights,
|
||||
tripKind
|
||||
} from './route';
|
||||
|
||||
const ROUTE: ITripRoute = {
|
||||
@@ -17,8 +19,8 @@ const ROUTE: ITripRoute = {
|
||||
originKz: '',
|
||||
originKato: '',
|
||||
stops: [
|
||||
{ id: 's1', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||
{ id: 's2', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||
],
|
||||
returnToOrigin: true,
|
||||
legTransport: { 'origin>s1': 'Авиа' }
|
||||
@@ -66,6 +68,21 @@ describe('сводка маршрута', () => {
|
||||
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
|
||||
expect(createStop(undefined).dateFrom).toBe('');
|
||||
});
|
||||
|
||||
it('зарубежная точка: страна и город, поездка зарубежная', () => {
|
||||
const beijing = { ...ROUTE.stops[1], abroad: true, country: 'Китай', city: 'Пекин' };
|
||||
const route: ITripRoute = { ...ROUTE, stops: [ROUTE.stops[0], beijing] };
|
||||
expect(routeCities(route)).toBe('Атырау, Китай, Пекин');
|
||||
expect(tripKind(route)).toBe(TRIP_KIND.abroad);
|
||||
expect(tripKind(ROUTE)).toBe(TRIP_KIND.domestic);
|
||||
// Следующая точка — в той же стране, что предыдущая.
|
||||
expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']);
|
||||
});
|
||||
|
||||
it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => {
|
||||
expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' });
|
||||
expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('хранение маршрута', () => {
|
||||
@@ -73,10 +90,11 @@ describe('хранение маршрута', () => {
|
||||
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
|
||||
});
|
||||
|
||||
it('заявка до справочника КАТО — город как был, без казахского названия', () => {
|
||||
it('старая заявка (до КАТО и признака страны) — город как был, точка по Казахстану', () => {
|
||||
const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] };
|
||||
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(routeCities(route)).toBe('Атырау');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { formatIsoDate } from '../../shared/dateUtils';
|
||||
// Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx.
|
||||
import { cityLabel } from '../../shared/dictionary/kato/cityLabel';
|
||||
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model';
|
||||
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID, TRIP_KIND, TripKind } from './model';
|
||||
import { tripDays } from './tripData';
|
||||
|
||||
/** Участок пути (переезд) между двумя соседними точками маршрута. */
|
||||
@@ -26,9 +26,27 @@ export function newId(prefix: string): string {
|
||||
const ORIGIN_FALLBACK = 'Город отправления';
|
||||
const STOP_FALLBACK = 'Точка маршрута';
|
||||
|
||||
/** Город точки на двух языках: «Кульсары (Құлсары)»; '' — не указан. */
|
||||
/**
|
||||
* Город точки: по Казахстану — на двух языках «Кульсары (Құлсары)», за рубежом —
|
||||
* страна и город «Китай, Пекин». '' — не указан.
|
||||
*/
|
||||
export function stopCityLabel(stop: IRouteStop): string {
|
||||
return cityLabel(stop.city, stop.cityKz);
|
||||
if (!stop.abroad) return cityLabel(stop.city, stop.cityKz);
|
||||
return [stop.country.trim(), stop.city.trim()].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Поля точки при смене «Казахстан / другая страна». Введённый город остаётся, но
|
||||
* за рубежом он уже не из справочника КАТО (казахское название и код сбрасываются),
|
||||
* а у точки по Казахстану страны нет.
|
||||
*/
|
||||
export function stopLocationPatch(abroad: boolean): Partial<IRouteStop> {
|
||||
return abroad ? { abroad: true, cityKz: '', cityKato: '' } : { abroad: false, country: '' };
|
||||
}
|
||||
|
||||
/** Вид поездки для колонки TripKind: хотя бы одна точка за рубежом — зарубежная. */
|
||||
export function tripKind(route: ITripRoute): TripKind {
|
||||
return route.stops.some((s) => s.abroad) ? TRIP_KIND.abroad : TRIP_KIND.domestic;
|
||||
}
|
||||
|
||||
/** Город отправления на двух языках; '' — не указан. */
|
||||
@@ -128,12 +146,15 @@ export function routeText(route: ITripRoute): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки:
|
||||
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым.
|
||||
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки,
|
||||
* страна (Казахстан / другая) — как у предыдущей: это не догадка, а то, что
|
||||
* человек сам уже ввёл; поля остаются редактируемыми.
|
||||
*/
|
||||
export function createStop(previous: IRouteStop | undefined): IRouteStop {
|
||||
return {
|
||||
id: newId('stop'),
|
||||
abroad: previous?.abroad ?? false,
|
||||
country: previous?.abroad ? previous.country : '',
|
||||
city: '',
|
||||
cityKz: '',
|
||||
cityKato: '',
|
||||
@@ -157,6 +178,9 @@ function toStop(value: unknown): IRouteStop | undefined {
|
||||
if (!id) return undefined;
|
||||
return {
|
||||
id,
|
||||
// Заявки до признака «другая страна» — все точки по Казахстану.
|
||||
abroad: raw.abroad === true,
|
||||
country: text(raw.country),
|
||||
city: text(raw.city),
|
||||
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
|
||||
cityKz: text(raw.cityKz),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { submitDateOnSave } from '../../shared/submitDate';
|
||||
import { formatIsoDate } from '../../shared/dateUtils';
|
||||
import { FORM_ID, FORM_TITLE, IFormData } from './model';
|
||||
import { filledTasks, formatTasks, tripDays } from './tripData';
|
||||
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute } from './route';
|
||||
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute, tripKind } from './route';
|
||||
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
|
||||
|
||||
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
|
||||
@@ -37,6 +37,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
||||
TripCompany: data.TripCompany || null,
|
||||
TripCompanyId: data.TripCompanyId || null,
|
||||
|
||||
TripKind: tripKind(route),
|
||||
TripRouteJson: serializeRoute(route),
|
||||
TripRoute: routeText(route) || null,
|
||||
TripCity: routeCities(route) || null,
|
||||
|
||||
@@ -10,7 +10,7 @@ const ROUTE: ITripRoute = {
|
||||
origin: 'Алматы',
|
||||
originKz: '',
|
||||
originKato: '',
|
||||
stops: [{ id: 's1', 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,
|
||||
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
|
||||
};
|
||||
@@ -61,6 +61,18 @@ describe('validation: маршрут', () => {
|
||||
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
|
||||
});
|
||||
|
||||
it('зарубежная точка — нужна страна', () => {
|
||||
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: ' ' }] };
|
||||
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-country']);
|
||||
});
|
||||
|
||||
it('зарубежная точка — объект можно ввести вручную, но не пустым', () => {
|
||||
const abroad = { ...ROUTE.stops[0], abroad: true, country: 'Китай', project: 'Завод Китайский', projectId: '' };
|
||||
expect(submit({ ...FILLED, Route: { ...ROUTE, stops: [abroad] } })).toEqual([]);
|
||||
const empty = { ...abroad, project: ' ' };
|
||||
expect(fields(submit({ ...FILLED, Route: { ...ROUTE, stops: [empty] } }))).toEqual(['stop-s1-project']);
|
||||
});
|
||||
|
||||
it('у каждого участка нужен вид транспорта', () => {
|
||||
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
|
||||
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
|
||||
@@ -78,7 +90,7 @@ describe('validation: маршрут', () => {
|
||||
});
|
||||
|
||||
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
|
||||
const second = { id: 's2', 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': 'Авиа' } };
|
||||
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } f
|
||||
*/
|
||||
export const fieldKey = {
|
||||
origin: 'route-origin',
|
||||
stopCountry: (stop: IRouteStop): string => `stop-${stop.id}-country`,
|
||||
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
|
||||
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
|
||||
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
|
||||
@@ -46,9 +47,16 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
|
||||
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] {
|
||||
const label = stopName(stop, index);
|
||||
const issues: IFieldIssue[] = [];
|
||||
if (stop.abroad && !stop.country.trim()) issues.push({ field: fieldKey.stopCountry(stop), label, message: 'укажите страну' });
|
||||
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
|
||||
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
|
||||
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
|
||||
// По Казахстану объект — именно из справочника (projectId): по нему разносят затраты по проектам.
|
||||
// За рубежом едут на чужую площадку (завод, выставка) — её в справочнике нет, достаточно текста.
|
||||
if (stop.abroad && !stop.project.trim()) {
|
||||
issues.push({ field: fieldKey.stopProject(stop), label, message: 'укажите объект или место назначения' });
|
||||
}
|
||||
if (!stop.abroad && !stop.projectId) {
|
||||
issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
|
||||
}
|
||||
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
|
||||
// Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи.
|
||||
if (stop.dateFrom && stop.dateFrom < today) {
|
||||
|
||||
Reference in New Issue
Block a user