feat: выбор командирующей организации в заявке на командировку

Сотрудник может работать в нескольких компаниях, а командирует одна —
теперь её выбирают из организаций сотрудника (Employee/ByEmail), должность
подставляется из выбранной компании. Одна организация выбирается сама.
Выбор хранится в TripCompany + TripCompanyId (id — под будущий маршрут).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 11:50:24 +05:00
parent 984eb65f5b
commit 513f6c1d30
11 changed files with 253 additions and 42 deletions
@@ -89,7 +89,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
// Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение // Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение
// работают со сметой, уже приведённой к текущим точкам и датам. // работают со сметой, уже приведённой к текущим точкам и датам.
const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) }; const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) };
const handleEmployeeChange = useTripEmployee(context, editId, setData); const employee = useTripEmployee(context, editId, data.Employee, setData);
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]); const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -130,7 +130,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts). // Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
const draftMissing = draftIssues(formData); const draftMissing = draftIssues(formData);
const submitMissing = submitIssues(formData); const submitMissing = submitIssues(formData, employee.organizations);
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`; if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
@@ -204,7 +204,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
onDeleted: onCancel onDeleted: onCancel
}); });
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange }; const sectionProps = { data: formData, set, context, employee };
return ( return (
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}> <FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
@@ -28,6 +28,8 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'SubmitDate', 'SubmitDate',
'Position', 'Position',
'Organization', 'Organization',
'TripCompany',
'TripCompanyId',
'TripRouteJson', 'TripRouteJson',
'TripPurpose', 'TripPurpose',
'TripTasksJson', 'TripTasksJson',
@@ -47,6 +49,8 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
Employee: employee?.EMail || '', Employee: employee?.EMail || '',
Position: (item.Position as string) || '', Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '', Organization: (item.Organization as string) || '',
TripCompany: (item.TripCompany as string) || '',
TripCompanyId: (item.TripCompanyId as string) || '',
Route: parseRoute(item.TripRouteJson), Route: parseRoute(item.TripRouteJson),
TripPurpose: (item.TripPurpose as string) || '', TripPurpose: (item.TripPurpose as string) || '',
Tasks: parseTasks(item.TripTasksJson), Tasks: parseTasks(item.TripTasksJson),
@@ -1,6 +1,7 @@
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель. // import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types'; import type { RequestTypeId } from '../../models/types';
import type { IEmployeeOrganization } from '../../shared/dictionary';
/** /**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -253,6 +254,13 @@ export interface IFormData {
Position: string; Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */ /** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string; Organization: string;
/**
* От какой компании едет сотрудник — одна из его организаций (Employee/ByEmail).
* Сотрудник может работать в нескольких компаниях, а командирует только одна.
*/
TripCompany: string;
/** CompanyId выбранной организации из справочника; '' — не выбрана. */
TripCompanyId: string;
Route: ITripRoute; Route: ITripRoute;
TripPurpose: string; TripPurpose: string;
@@ -270,16 +278,25 @@ export interface IFormData {
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void; export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Сотрудник заявки и выбор организации, от которой он едет (см. useTripEmployee). */
export interface ITripEmployee {
/** Организации текущего сотрудника — варианты «Командирующей организации». */
organizations: IEmployeeOrganization[];
onEmployeeChange: (login: string) => void;
/** key — значение варианта (см. tripCompany.ts/companyKey); '' — сбросить выбор. */
onCompanyChange: (key: string) => void;
}
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */ /** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */
export interface ISectionProps { export interface ISectionProps {
data: IFormData; data: IFormData;
set: SetField; set: SetField;
context: WebPartContext; context: WebPartContext;
/** /**
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля * Выбор сотрудника и организации подтягивает должность и организации — поэтому
* отдельный обработчик. Не передан (режим чтения) — правка через set. * у этих полей свои обработчики. Не передан (режим чтения) — правка через set.
*/ */
onEmployeeChange?: (login: string) => void; employee?: ITripEmployee;
} }
export const DEFAULT_PURPOSE = 'Производственная необходимость'; export const DEFAULT_PURPOSE = 'Производственная необходимость';
@@ -327,6 +344,8 @@ export const EMPTY_DATA: IFormData = {
Employee: '', Employee: '',
Position: '', Position: '',
Organization: '', Organization: '',
TripCompany: '',
TripCompanyId: '',
Route: EMPTY_ROUTE, Route: EMPTY_ROUTE,
TripPurpose: DEFAULT_PURPOSE, TripPurpose: DEFAULT_PURPOSE,
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять. // Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
@@ -8,6 +8,8 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" }, { "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" }, { "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
@@ -1,28 +1,49 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationField, Select } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
import { companyOptions, selectedCompanyKey } from '../tripCompany';
/** /**
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого * Командируемый: по умолчанию сам инициатор, но можно выбрать другого
* сотрудника (руководитель оформляет за подчинённого) — должность и * сотрудника (руководитель оформляет за подчинённого) — должность и
* организации подтянутся по выбранному. Дату подачи ставит форма. * организации подтянутся по выбранному. Дату подачи ставит форма.
* Сотрудник может работать в нескольких компаниях — тогда выбирают, от какой он едет.
*/ */
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => ( export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, employee }) => {
<FormGrid> const options = companyOptions(employee?.organizations ?? [], data);
<FormField label="Сотрудник" htmlFor="Employee" required full> // Заявки, поданные до появления выбора организации, показывают прежний список всех организаций.
<PeopleField const isLegacyRequest = !employee && !data.TripCompany;
context={context}
value={data.Employee} return (
onChange={onEmployeeChange ?? ((v) => set('Employee', v))} <FormGrid>
placeholder={EMPLOYEE_PLACEHOLDER} <FormField label="Сотрудник" htmlFor="Employee" required full>
/> <PeopleField
</FormField> context={context}
<FormField label="Должность" htmlFor="Position" required> value={data.Employee}
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} /> onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
</FormField> placeholder={EMPLOYEE_PLACEHOLDER}
<FormField label="Дата подачи" htmlFor="SubmitDate"> />
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly /> </FormField>
</FormField> <FormField label="Должность" htmlFor="Position" required>
<OrganizationField value={data.Organization} /> <TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormGrid> </FormField>
); <FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
{isLegacyRequest && <OrganizationField value={data.Organization} />}
{!isLegacyRequest && options.length > 0 && (
<FormField label="Командирующая организация" htmlFor="TripCompany" required full>
<Select
id="TripCompany"
value={selectedCompanyKey(data)}
onChange={(key) => employee?.onCompanyChange(key)}
options={options}
placeholder="Выберите, от какой организации командировка"
// Единственная организация выбрана автоматически — выбирать не из чего.
readOnly={options.length === 1 && !!data.TripCompany}
/>
</FormField>
)}
</FormGrid>
);
};
@@ -34,6 +34,8 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
EmployeeId: employeeId, EmployeeId: employeeId,
Position: data.Position || null, Position: data.Position || null,
Organization: data.Organization || null, Organization: data.Organization || null,
TripCompany: data.TripCompany || null,
TripCompanyId: data.TripCompanyId || null,
TripRouteJson: serializeRoute(route), TripRouteJson: serializeRoute(route),
TripRoute: routeText(route) || null, TripRoute: routeText(route) || null,
@@ -0,0 +1,57 @@
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { EMPTY_DATA, IFormData } from './model';
import { applyCompany, applyEmployeeCompany, companyOptions } from './tripCompany';
const org = (companyId: string, company: string, position: string): IEmployeeOrganization => ({
id: `${companyId}-${position}`,
fio: 'Нурумова Виктория',
email: 'nurumova@tnsu.kz',
iin: '',
company,
companyId,
section: '',
departmentId: '',
position,
mobile: ''
});
const MONTAZHNIK = org('c1', 'ТОО Монтажник-Т', 'Директор');
const ASTEL = org('c2', 'ТОО АСТЕЛ-К', 'Казначей');
describe('tripCompany: варианты', () => {
it('«Компания — должность», дубль компании отбрасывается', () => {
const options = companyOptions([MONTAZHNIK, ASTEL, org('c2', 'ТОО АСТЕЛ-К', 'Бухгалтер')], EMPTY_DATA);
expect(options).toEqual([
{ value: 'c1', label: 'ТОО Монтажник-Т — Директор' },
{ value: 'c2', label: 'ТОО АСТЕЛ-К — Казначей' }
]);
});
it('сохранённая организация видна, даже если справочник её не вернул', () => {
const data: IFormData = { ...EMPTY_DATA, TripCompany: 'ТОО Старая', TripCompanyId: 'old' };
expect(companyOptions([], data)).toEqual([{ value: 'old', label: 'ТОО Старая' }]);
});
});
describe('tripCompany: выбор', () => {
it('выбор организации ставит должность в ней', () => {
const data = applyCompany({ ...EMPTY_DATA, Position: 'Директор' }, ASTEL);
expect([data.TripCompany, data.TripCompanyId, data.Position]).toEqual(['ТОО АСТЕЛ-К', 'c2', 'Казначей']);
});
it('одна организация выбирается сразу', () => {
const data = applyEmployeeCompany(EMPTY_DATA, [MONTAZHNIK]);
expect([data.TripCompanyId, data.Position]).toEqual(['c1', 'Директор']);
});
it('несколько — выбор и должность пустые', () => {
const prev = { ...EMPTY_DATA, TripCompany: 'ТОО Монтажник-Т', TripCompanyId: 'c1', Position: 'Директор' };
const data = applyEmployeeCompany(prev, [MONTAZHNIK, ASTEL]);
expect([data.TripCompanyId, data.Position]).toEqual(['', '']);
});
it('нет в справочнике — должность остаётся (введут руками)', () => {
const data = applyEmployeeCompany({ ...EMPTY_DATA, Position: 'Инженер' }, []);
expect([data.TripCompanyId, data.Position]).toEqual(['', 'Инженер']);
});
});
@@ -0,0 +1,57 @@
// Только типы: модуль чистый и тестируется без SharePoint (сервисы справочника тянут sp-http).
import type { IEmployeeOrganization } from '../../shared/dictionary';
import type { ISelectOption } from '../../shared/ui';
import { IFormData } from './model';
/** Значение варианта в списке: CompanyId из справочника, у записи без id — название. */
export function companyKey(org: IEmployeeOrganization): string {
return org.companyId || org.company;
}
/** Значение выбранной организации в списке ('' — не выбрана). */
export function selectedCompanyKey(data: IFormData): string {
return data.TripCompanyId || data.TripCompany;
}
/**
* Варианты «Командирующей организации» — «Компания — должность». Если в одной
* компании у сотрудника несколько записей, остаётся первая: выбирают компанию,
* а не ставку. Сохранённая организация, которой нет среди вариантов (просмотр
* заявки, справочник недоступен, сотрудник уже ушёл из компании), всё равно
* показывается — иначе поле выглядело бы пустым.
*/
export function companyOptions(organizations: IEmployeeOrganization[], data: IFormData): ISelectOption[] {
const options: ISelectOption[] = [];
organizations.forEach((org) => {
const value = companyKey(org);
if (!value || options.some((o) => o.value === value)) return;
options.push({ value, label: org.position ? `${org.company} — ${org.position}` : org.company });
});
const saved = selectedCompanyKey(data);
if (saved && !options.some((o) => o.value === saved)) {
options.push({ value: saved, label: data.TripCompany });
}
return options;
}
/** Выбрать организацию: компания + должность сотрудника именно в ней. undefined — сбросить выбор. */
export function applyCompany(data: IFormData, org: IEmployeeOrganization | undefined): IFormData {
return {
...data,
TripCompany: org?.company || '',
TripCompanyId: org?.companyId || '',
Position: org?.position || data.Position
};
}
/**
* Организация и должность нового сотрудника заявки. Одна организация —
* выбирается сразу. Несколько — выбор за человеком, а должность пустая до
* выбора: в каждой компании она своя, и угадывать её по первой записи нельзя.
* Сотрудник не найден в справочнике — должность остаётся как была (введут руками).
*/
export function applyEmployeeCompany(data: IFormData, organizations: IEmployeeOrganization[]): IFormData {
if (organizations.length === 1) return applyCompany(data, organizations[0]);
const cleared = applyCompany(data, undefined);
return organizations.length > 1 ? { ...cleared, Position: '' } : cleared;
}
@@ -1,22 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate'; import { todaySubmitDate } from '../../shared/submitDate';
import { IFormData } from './model'; import { IFormData, ITripEmployee } from './model';
import { applyCompany, applyEmployeeCompany, companyKey } from './tripCompany';
type SetData = React.Dispatch<React.SetStateAction<IFormData>>; type SetData = React.Dispatch<React.SetStateAction<IFormData>>;
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */ /** Подставить организации, командирующую организацию и должность сотрудника из Employee/ByEmail. */
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void { function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
getEmployeeOrganizations(context, email) getEmployeeOrganizations(context, email)
.then((records) => { .then((records) => {
if (!isAlive()) return; if (!isAlive()) return;
setData((prev) => ({ setData((prev) => applyEmployeeCompany({ ...prev, Organization: formatEmployeeOrganizations(records) }, records));
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
}) })
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется. // Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
.catch(() => undefined); .catch(() => undefined);
@@ -24,10 +22,18 @@ function fillProfile(context: WebPartContext, email: string, setData: SetData, i
/** /**
* Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь, * Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь,
* его должность, организации и сегодняшняя дата подачи. Возвращает обработчик * его должность, организации и сегодняшняя дата подачи. Выбор другого сотрудника
* выбора другого сотрудника — он подтягивает должность и организации выбранного. * подтягивает профиль выбранного. Список организаций грузится и для открытого
* черновика — но там сохранённые должность и организацию не перезаписываем.
*/ */
export function useTripEmployee(context: WebPartContext, editId: number | undefined, setData: SetData): (login: string) => void { export function useTripEmployee(
context: WebPartContext,
editId: number | undefined,
employee: string,
setData: SetData
): ITripEmployee {
const [organizations, setOrganizations] = useState<IEmployeeOrganization[]>([]);
useEffect(() => { useEffect(() => {
if (editId) return; if (editId) return;
let alive = true; let alive = true;
@@ -40,8 +46,36 @@ export function useTripEmployee(context: WebPartContext, editId: number | undefi
// context — единственный экземпляр WebPartContext на всё время жизни веб-части. // context — единственный экземпляр WebPartContext на всё время жизни веб-части.
}, [editId, context, setData]); }, [editId, context, setData]);
return (login: string): void => { // Ответ справочника кешируется по e-mail (см. employeeOrganizations.service.ts) — повторного запроса нет.
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' })); useEffect(() => {
if (login) fillProfile(context, login, setData, () => true); let alive = true;
const load = employee ? getEmployeeOrganizations(context, employee) : Promise.resolve<IEmployeeOrganization[]>([]);
load
.then((records) => {
if (alive) setOrganizations(records);
})
.catch(() => {
if (alive) setOrganizations([]);
});
return () => {
alive = false;
};
}, [context, employee]);
const onEmployeeChange = (login: string): void => {
if (!login) {
// Сотрудник очищен — его организации и выбранная компания к заявке больше не относятся.
setData((prev) => applyCompany({ ...prev, Employee: '', Organization: '' }, undefined));
return;
}
setData((prev) => ({ ...prev, Employee: login }));
fillProfile(context, login, setData, () => true);
}; };
const onCompanyChange = (key: string): void => {
const org = organizations.find((o) => companyKey(o) === key);
setData((prev) => applyCompany(prev, org));
};
return { organizations, onEmployeeChange, onCompanyChange };
} }
@@ -30,6 +30,13 @@ describe('validation: форма', () => {
expect(draftIssues(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([]);
});
it('черновику нужен только сотрудник', () => { it('черновику нужен только сотрудник', () => {
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']); expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
}); });
@@ -1,4 +1,5 @@
import type { IFieldIssue } from '../../shared/ui'; import type { IFieldIssue } from '../../shared/ui';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model'; import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData'; import { filledTasks } from './tripData';
import { legLabel, routeLegs } from './route'; import { legLabel, routeLegs } from './route';
@@ -116,11 +117,18 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
return [...issues, ...stopDateOrderIssues(data.Route)]; return [...issues, ...stopDateOrderIssues(data.Route)];
} }
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */ /**
export function submitIssues(data: IFormData): IFieldIssue[] { * Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно).
* organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую.
*/
export function submitIssues(data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] {
const issues: IFieldIssue[] = []; const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' }); if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' }); if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
// Нет организаций (сотрудника нет в справочнике / справочник недоступен) — выбирать не из чего, не блокируем.
if (organizations.length > 0 && !data.TripCompany) {
issues.push({ field: 'TripCompany', label: 'Командирующая организация', message: 'выберите, от какой организации командировка' });
}
issues.push(...routeIssues(data.Route)); issues.push(...routeIssues(data.Route));
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' }); if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
if (filledTasks(data.Tasks).length === 0) { if (filledTasks(data.Tasks).length === 0) {