From 513f6c1d30416d1f59419f9666efaa2e6392b1e6 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 11:50:24 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B8=D1=80=D1=83=D1=8E=D1=89?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B2=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сотрудник может работать в нескольких компаниях, а командирует одна — теперь её выбирают из организаций сотрудника (Employee/ByEmail), должность подставляется из выбранной компании. Одна организация выбирается сама. Выбор хранится в TripCompany + TripCompanyId (id — под будущий маршрут). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/BusinessTrip/BusinessTripForm.tsx | 6 +- .../requestPortal/forms/BusinessTrip/load.ts | 4 ++ .../requestPortal/forms/BusinessTrip/model.ts | 25 +++++++- .../forms/BusinessTrip/provisioning.json | 2 + .../BusinessTrip/sections/EmployeeSection.tsx | 61 ++++++++++++------ .../forms/BusinessTrip/submit.ts | 2 + .../forms/BusinessTrip/tripCompany.test.ts | 57 +++++++++++++++++ .../forms/BusinessTrip/tripCompany.ts | 57 +++++++++++++++++ .../forms/BusinessTrip/useTripEmployee.ts | 62 ++++++++++++++----- .../forms/BusinessTrip/validation.test.ts | 7 +++ .../forms/BusinessTrip/validation.ts | 12 +++- 11 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/tripCompany.test.ts create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/tripCompany.ts diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx index 335b6d8..7f61a75 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx @@ -89,7 +89,7 @@ const BusinessTripForm: React.FC = ({ // Суточные строятся по маршруту (см. 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 = ({ // Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. 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 = ({ onDeleted: onCancel }); - const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange }; + const sectionProps = { data: formData, set, context, employee }; return ( diff --git a/src/webparts/requestPortal/forms/BusinessTrip/load.ts b/src/webparts/requestPortal/forms/BusinessTrip/load.ts index a0c1d76..793e5fd 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/load.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/load.ts @@ -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), diff --git a/src/webparts/requestPortal/forms/BusinessTrip/model.ts b/src/webparts/requestPortal/forms/BusinessTrip/model.ts index 6f94a9f..ecb474e 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/model.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/model.ts @@ -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 = (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, // Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json b/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json index a1a79af..bba3c97 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json +++ b/src/webparts/requestPortal/forms/BusinessTrip/provisioning.json @@ -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" }, diff --git a/src/webparts/requestPortal/forms/BusinessTrip/sections/EmployeeSection.tsx b/src/webparts/requestPortal/forms/BusinessTrip/sections/EmployeeSection.tsx index be0f845..e063095 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/sections/EmployeeSection.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/sections/EmployeeSection.tsx @@ -1,28 +1,49 @@ 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 = ({ data, set, context, onEmployeeChange }) => ( - - - set('Employee', v))} - placeholder={EMPLOYEE_PLACEHOLDER} - /> - - - set('Position', v)} /> - - - set('SubmitDate', v)} readOnly /> - - - -); +export const EmployeeSection: React.FC = ({ data, set, context, employee }) => { + const options = companyOptions(employee?.organizations ?? [], data); + // Заявки, поданные до появления выбора организации, показывают прежний список всех организаций. + const isLegacyRequest = !employee && !data.TripCompany; + + return ( + + + set('Employee', v))} + placeholder={EMPLOYEE_PLACEHOLDER} + /> + + + set('Position', v)} /> + + + set('SubmitDate', v)} readOnly /> + + {isLegacyRequest && } + {!isLegacyRequest && options.length > 0 && ( + +