feat: выбор командирующей организации в заявке на командировку
Сотрудник может работать в нескольких компаниях, а командирует одна — теперь её выбирают из организаций сотрудника (Employee/ByEmail), должность подставляется из выбранной компании. Одна организация выбирается сама. Выбор хранится в TripCompany + TripCompanyId (id — под будущий маршрут). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,19 +1,26 @@
|
|||||||
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 }) => {
|
||||||
|
const options = companyOptions(employee?.organizations ?? [], data);
|
||||||
|
// Заявки, поданные до появления выбора организации, показывают прежний список всех организаций.
|
||||||
|
const isLegacyRequest = !employee && !data.TripCompany;
|
||||||
|
|
||||||
|
return (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.Employee}
|
value={data.Employee}
|
||||||
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
|
onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
|
||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -23,6 +30,20 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, o
|
|||||||
<FormField label="Дата подачи" htmlFor="SubmitDate">
|
<FormField label="Дата подачи" htmlFor="SubmitDate">
|
||||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
||||||
</FormField>
|
</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>
|
</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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user