diff --git a/docs/forms.md b/docs/forms.md index ef4e706..3684967 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -49,7 +49,7 @@ | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ | | [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | | [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены | -| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены | +| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки = город из справочника КАТО на русском и казахском + объект из справочника + даты не задним числом, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ | diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index 5d8f8e9..6c45892 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -50,6 +50,9 @@ узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко. - Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл); прибытие раньше отъезда из предыдущей точки — ошибка. + - **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня + (сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или + «устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется. - Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом), `TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна), `TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период). diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx index 80a8d27..84af06e 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/route/RouteStopCard.tsx @@ -6,6 +6,7 @@ import { CityPicker, ProjectPicker } from '../../../../shared/dictionary'; import { CITY_PLACEHOLDER, IRouteStop, PROJECT_PLACEHOLDER } from '../../model'; import { stopNights } from '../../route'; import { plural } from '../../tripData'; +import { todaySubmitDate } from '../../../../shared/submitDate'; import { fieldKey } from '../../validation'; interface Props { @@ -63,6 +64,8 @@ export const RouteStopCard: React.FC = ({ stop, index, context, onChange, type="date" value={stop.dateFrom} onChange={(v) => onChange({ dateFrom: v })} + // Прошедшие дни в календаре недоступны; набранную вручную дату ловит validation.ts. + min={todaySubmitDate()} /> @@ -71,7 +74,7 @@ export const RouteStopCard: React.FC = ({ stop, index, context, onChange, type="date" value={stop.dateTo} onChange={(v) => onChange({ dateTo: v })} - min={stop.dateFrom || undefined} + min={stop.dateFrom || todaySubmitDate()} /> diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts index def4d01..839cf86 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts @@ -1,5 +1,7 @@ import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model'; import { createArticle } from './estimate'; +import type { IEmployeeOrganization } from '../../shared/dictionary'; +import type { IFieldIssue } from '../../shared/ui'; import { draftIssues, submitIssues } from './validation'; const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false }); @@ -24,19 +26,23 @@ const FILLED: IFormData = { TripObligationAccepted: true }; +// Фиксированное «сегодня» — раньше дат маршрута в фикстурах, чтобы тесты не зависели от текущей даты. +const TODAY = '2026-10-01'; +const submit = (data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] => submitIssues(data, organizations, TODAY); + const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field); describe('validation: форма', () => { it('заполненная заявка — без проблем', () => { - expect(submitIssues(FILLED)).toEqual([]); + expect(submit(FILLED)).toEqual([]); expect(draftIssues(FILLED)).toEqual([]); }); it('командирующая организация обязательна, только если есть из чего выбрать', () => { const orgs = [{ id: '1', fio: '', email: '', iin: '', company: 'ТОО А', companyId: 'a', section: '', departmentId: '', position: '', mobile: '' }]; - expect(fields(submitIssues(FILLED, orgs))).toEqual(['TripCompany']); - expect(submitIssues({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]); - expect(submitIssues(FILLED, [])).toEqual([]); + expect(fields(submit(FILLED, orgs))).toEqual(['TripCompany']); + expect(submit({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]); + expect(submit(FILLED, [])).toEqual([]); }); it('черновику нужен только сотрудник', () => { @@ -45,19 +51,25 @@ describe('validation: форма', () => { it('пустые задачи и обязательство', () => { const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false }; - expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']); + expect(fields(submit(data))).toEqual(['Tasks', 'TripObligationAccepted']); }); }); describe('validation: маршрут', () => { it('объект только из справочника', () => { const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] }; - expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']); + expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']); }); it('у каждого участка нужен вид транспорта', () => { const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } }; - expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']); + expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']); + }); + + it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => { + expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']); + expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]); + expect(draftIssues(FILLED)).toEqual([]); }); it('отъезд раньше прибытия блокирует даже черновик', () => { @@ -68,7 +80,7 @@ describe('validation: маршрут', () => { it('прибытие в следующую точку раньше отъезда из предыдущей', () => { const second = { id: 's2', 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(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']); + expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']); }); }); @@ -78,27 +90,27 @@ describe('validation: смета', () => { it('включённая статья без суммы — подсвечивается пустое поле', () => { const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] }; const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] }; - expect(fields(submitIssues(data))).toEqual([`item-${accommodation.items[0].id}-price`]); + expect(fields(submit(data))).toEqual([`item-${accommodation.items[0].id}-price`]); }); it('суточные: дни из маршрута, от человека — только кол-во МРП', () => { const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] }; const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] }; const estimate = (article: IEstimateArticle): IEstimateArticle[] => [article, off(travel), off(accommodation)]; - expect(fields(submitIssues({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]); - expect(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]); + expect(fields(submit({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]); + expect(submit({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]); }); it('при нескольких вариантах позицию нужно привязать', () => { // В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен. const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] }; const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] }; - expect(fields(submitIssues(data))).toEqual([`item-${travel.items[0].id}-link`]); + expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-link`]); }); it('другой расход требует название и сумму', () => { const other = createArticle('other', ROUTE); const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] }; - expect(fields(submitIssues(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]); + expect(fields(submit(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]); }); }); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts index d18cb6f..8e9c7b4 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts @@ -1,5 +1,6 @@ import type { IFieldIssue } from '../../shared/ui'; import type { IEmployeeOrganization } from '../../shared/dictionary'; +import { todaySubmitDate } from '../../shared/submitDate'; import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model'; import { filledTasks } from './tripData'; import { legLabel, routeLegs, stopCityLabel } from './route'; @@ -42,13 +43,17 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] { } /** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */ -function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined): IFieldIssue[] { +function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] { const label = stopName(stop, index); const issues: IFieldIssue[] = []; if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' }); // Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам. if (!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) { + issues.push({ field: fieldKey.stopFrom(stop), label, message: 'дата прибытия уже прошла' }); + } if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' }); if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) { issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' }); @@ -57,10 +62,10 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde } /** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */ -function routeIssues(route: ITripRoute): IFieldIssue[] { +function routeIssues(route: ITripRoute, today: string): IFieldIssue[] { const issues: IFieldIssue[] = []; if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' }); - route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1]))); + route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today))); issues.push(...stopDateOrderIssues(route)); routeLegs(route).forEach((leg) => { if (!route.legTransport[leg.id]) { @@ -121,8 +126,13 @@ export function draftIssues(data: IFormData): IFieldIssue[] { /** * Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). * organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую. + * today — YYYY-MM-DD; параметром, чтобы тесты не зависели от текущей даты. */ -export function submitIssues(data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] { +export function submitIssues( + data: IFormData, + organizations: IEmployeeOrganization[] = [], + today: string = todaySubmitDate() +): IFieldIssue[] { const issues: IFieldIssue[] = []; if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' }); if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' }); @@ -130,7 +140,7 @@ export function submitIssues(data: IFormData, organizations: IEmployeeOrganizati if (organizations.length > 0 && !data.TripCompany) { issues.push({ field: 'TripCompany', label: 'Командирующая организация', message: 'выберите, от какой организации командировка' }); } - issues.push(...routeIssues(data.Route)); + issues.push(...routeIssues(data.Route, today)); if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' }); if (filledTasks(data.Tasks).length === 0) { issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });