From 17f448a1c296e2cd42fbab5809aab91b3683dd58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 23 Oct 2026 15:02:22 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=9A=D0=B0=D0=B7=D0=B0=D1=85=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=83=20=D0=B8=20=D0=B7=D0=B0=20=D1=80=D1=83?= =?UTF-8?q?=D0=B1=D0=B5=D0=B6=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У каждой точки маршрута — «Казахстан | Другая страна» (бывают смешанные поездки). За рубежом страна и город вводятся вручную, подпись «Китай, Пекин», объект можно вписать своим текстом (завод, выставка — их нет в справочнике). Новая колонка TripKind («По Казахстану» / «Зарубежная») — хотя бы одна точка за рубежом делает поездку зарубежной; пригодится для маршрута и отчётов. Переключатель «Форма | Схема» вынесен в общий SegmentedSwitch. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/BusinessTrip/README.md | 12 ++++- ...odule.scss => SegmentedSwitch.module.scss} | 4 +- .../components/SegmentedSwitch.tsx | 36 +++++++++++++ .../components/route/RouteStopCard.tsx | 48 ++++++++++++----- .../components/route/RouteViewSwitch.tsx | 25 +++------ .../components/route/StopPlaceFields.tsx | 51 +++++++++++++++++++ .../forms/BusinessTrip/estimate.test.ts | 4 +- .../requestPortal/forms/BusinessTrip/model.ts | 31 +++++++++-- .../forms/BusinessTrip/provisioning.json | 1 + .../forms/BusinessTrip/route.test.ts | 28 ++++++++-- .../requestPortal/forms/BusinessTrip/route.ts | 34 +++++++++++-- .../forms/BusinessTrip/submit.ts | 3 +- .../forms/BusinessTrip/validation.test.ts | 16 +++++- .../forms/BusinessTrip/validation.ts | 12 ++++- 14 files changed, 249 insertions(+), 56 deletions(-) rename src/webparts/requestPortal/forms/BusinessTrip/components/{route/RouteViewSwitch.module.scss => SegmentedSwitch.module.scss} (90%) create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.tsx create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/route/StopPlaceFields.tsx diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index aa6c38f..bc2e29f 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -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`), на линиях — вид транспорта, внизу сводка diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteViewSwitch.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.module.scss similarity index 90% rename from src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteViewSwitch.module.scss rename to src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.module.scss index 8187e36..70f94ca 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteViewSwitch.module.scss +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.module.scss @@ -1,6 +1,6 @@ -@import '../../../../../../styles/design-tokens'; +@import '../../../../../styles/design-tokens'; -// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете. +// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете (AmountFields). .switch { display: inline-flex; align-self: flex-start; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.tsx new file mode 100644 index 0000000..43a325a --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/SegmentedSwitch.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import styles from './SegmentedSwitch.module.scss'; + +export interface ISegmentedOption { + value: T; + label: string; +} + +interface Props { + value: T; + options: ReadonlyArray>; + onChange: (value: T) => void; + /** Подпись группы для экранных дикторов. */ + ariaLabel: string; + className?: string; +} + +/** Сегментный переключатель из 2–3 вариантов: «Форма | Схема», «Казахстан | Другая страна». */ +export function SegmentedSwitch({ value, options, onChange, ariaLabel, className }: Props): JSX.Element { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx index 84af06e..2f5fb9e 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx @@ -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> = [ + { value: 'domestic', label: 'Казахстан' }, + { value: 'abroad', label: 'Другая страна' } +]; + +/** + * Точка маршрута: Казахстан или другая страна, город, объект из справочника и даты + * пребывания. В режиме чтения переключатель не нужен — страна видна в полях. + */ export const RouteStopCard: React.FC = ({ stop, index, context, onChange, onRemove }) => { + const readOnly = useFormReadOnly(); const nights = stopNights(stop); return ( @@ -40,16 +53,23 @@ export const RouteStopCard: React.FC = ({ stop, index, context, onChange, )} + {!readOnly && ( + onChange(stopLocationPatch(location === 'abroad'))} + ariaLabel={`Где точка ${index + 1}`} + /> + )}
- - onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })} - placeholder={CITY_PLACEHOLDER} - /> - - + + {/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */} + void; } -const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [ - { mode: 'form', label: 'Форма' }, - { mode: 'scheme', label: 'Схема' } +const MODES: ReadonlyArray> = [ + { value: 'form', label: 'Форма' }, + { value: 'scheme', label: 'Схема' } ]; -/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */ +/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */ export const RouteViewSwitch: React.FC = ({ value, onChange }) => ( -
- {MODES.map(({ mode, label }) => ( - - ))} -
+ ); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/StopPlaceFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/StopPlaceFields.tsx new file mode 100644 index 0000000..9240cef --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/StopPlaceFields.tsx @@ -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) => void; +} + +/** + * Где находится точка. По Казахстану — город из справочника КАТО (оба языка + код); + * за рубежом — страна и город вручную: справочник только по РК. + * Рендерит ячейки сетки карточки точки (без своей обёртки). + */ +export const StopPlaceFields: React.FC = ({ stop, onChange }) => { + if (!stop.abroad) { + return ( + + onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })} + placeholder={CITY_PLACEHOLDER} + /> + + ); + } + + return ( + <> + + onChange({ country: v })} + placeholder={COUNTRY_PLACEHOLDER} + /> + + + onChange({ city: v })} + placeholder={ABROAD_CITY_PLACEHOLDER} + /> + + + ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts index 749de8f..eb46caf 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts @@ -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: {} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/model.ts b/src/webparts/requestPortal/forms/BusinessTrip/model.ts index 581575d..5388a4d 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/model.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/model.ts @@ -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 CITY_PLACEHOLDER = 'Начните вводить город: Атырау, Құлсары…'; +export const COUNTRY_PLACEHOLDER = 'Напр.: Китай'; +export const ABROAD_CITY_PLACEHOLDER = 'Напр.: Пекин'; +export const CITY_PLACEHOLDER ='Начните вводить город: Атырау, Құлсары…'; export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…'; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json b/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json index 9825541..f03555f 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json +++ b/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json @@ -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" }, diff --git a/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts index a88aeee..c47572c 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/route.test.ts @@ -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('Атырау'); }); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/route.ts b/src/webparts/requestPortal/forms/BusinessTrip/route.ts index f85daab..1cde636 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/route.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/route.ts @@ -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 { + 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), diff --git a/src/webparts/requestPortal/forms/BusinessTrip/submit.ts b/src/webparts/requestPortal/forms/BusinessTrip/submit.ts index 2a8928e..7fa044a 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/submit.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/submit.ts @@ -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, diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts index 839cf86..ec4a32b 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts @@ -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']); }); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts index a34f634..5a96f0e 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts @@ -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) {