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): секции, проверки и сохранение
// работают со сметой, уже приведённой к текущим точкам и датам.
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 requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -130,7 +130,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
const draftMissing = draftIssues(formData);
const submitMissing = submitIssues(formData);
const submitMissing = submitIssues(formData, employee.organizations);
const validate = (): string | undefined => {
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
@@ -204,7 +204,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
onDeleted: onCancel
});
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange };
const sectionProps = { data: formData, set, context, employee };
return (
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
@@ -28,6 +28,8 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'SubmitDate',
'Position',
'Organization',
'TripCompany',
'TripCompanyId',
'TripRouteJson',
'TripPurpose',
'TripTasksJson',
@@ -47,6 +49,8 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '',
TripCompany: (item.TripCompany as string) || '',
TripCompanyId: (item.TripCompanyId as string) || '',
Route: parseRoute(item.TripRouteJson),
TripPurpose: (item.TripPurpose as string) || '',
Tasks: parseTasks(item.TripTasksJson),
@@ -1,6 +1,7 @@
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types';
import type { IEmployeeOrganization } from '../../shared/dictionary';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -253,6 +254,13 @@ export interface IFormData {
Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string;
/**
* От какой компании едет сотрудник — одна из его организаций (Employee/ByEmail).
* Сотрудник может работать в нескольких компаниях, а командирует только одна.
*/
TripCompany: string;
/** CompanyId выбранной организации из справочника; '' — не выбрана. */
TripCompanyId: string;
Route: ITripRoute;
TripPurpose: string;
@@ -270,16 +278,25 @@ export interface IFormData {
/** Обновление одного поля формы с сохранением типа значения. */
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 / справочники). */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/**
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля
* отдельный обработчик. Не передан (режим чтения) — правка через set.
* Выбор сотрудника и организации подтягивает должность и организации — поэтому
* у этих полей свои обработчики. Не передан (режим чтения) — правка через set.
*/
onEmployeeChange?: (login: string) => void;
employee?: ITripEmployee;
}
export const DEFAULT_PURPOSE = 'Производственная необходимость';
@@ -327,6 +344,8 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
Organization: '',
TripCompany: '',
TripCompanyId: '',
Route: EMPTY_ROUTE,
TripPurpose: DEFAULT_PURPOSE,
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
@@ -8,6 +8,8 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
@@ -1,19 +1,26 @@
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 { companyOptions, selectedCompanyKey } from '../tripCompany';
/**
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого
* сотрудника (руководитель оформляет за подчинённого) — должность и
* организации подтянутся по выбранному. Дату подачи ставит форма.
* Сотрудник может работать в нескольких компаниях — тогда выбирают, от какой он едет.
*/
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, employee }) => {
const options = companyOptions(employee?.organizations ?? [], data);
// Заявки, поданные до появления выбора организации, показывают прежний список всех организаций.
const isLegacyRequest = !employee && !data.TripCompany;
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
value={data.Employee}
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
@@ -23,6 +30,20 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, o
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<OrganizationField value={data.Organization} />
{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,
Position: data.Position || null,
Organization: data.Organization || null,
TripCompany: data.TripCompany || null,
TripCompanyId: data.TripCompanyId || null,
TripRouteJson: serializeRoute(route),
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 { useEffect } from 'react';
import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import type { IEmployeeOrganization } from '../../shared/dictionary';
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>>;
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */
/** Подставить организации, командирующую организацию и должность сотрудника из Employee/ByEmail. */
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
getEmployeeOrganizations(context, email)
.then((records) => {
if (!isAlive()) return;
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
setData((prev) => applyEmployeeCompany({ ...prev, Organization: formatEmployeeOrganizations(records) }, records));
})
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
.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(() => {
if (editId) return;
let alive = true;
@@ -40,8 +46,36 @@ export function useTripEmployee(context: WebPartContext, editId: number | undefi
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
}, [editId, context, setData]);
return (login: string): void => {
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' }));
if (login) fillProfile(context, login, setData, () => true);
// Ответ справочника кешируется по e-mail (см. employeeOrganizations.service.ts) — повторного запроса нет.
useEffect(() => {
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([]);
});
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('черновику нужен только сотрудник', () => {
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
});
@@ -1,4 +1,5 @@
import type { IFieldIssue } from '../../shared/ui';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData';
import { legLabel, routeLegs } from './route';
@@ -116,11 +117,18 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
return [...issues, ...stopDateOrderIssues(data.Route)];
}
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
export function submitIssues(data: IFormData): IFieldIssue[] {
/**
* Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно).
* organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую.
*/
export function submitIssues(data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', 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));
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
if (filledTasks(data.Tasks).length === 0) {