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