From 58e4d3c868ebd8bc8e04e943e0981fbafe013d8b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 13:56:59 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=BF=D1=83=D1=81=D0=BA=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=BE=D0=BB=D0=B6=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B7=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0,?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20=D0=BE=D1=80?= =?UTF-8?q?=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B9=20=D1=81?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убраны редактируемые поля «Должность» и «Подразделение». Должность и подразделение подтягиваются из Employee/ByEmail и показываются таблицей «Организация · Должность · Подразделение» — своё подразделение на каждую организацию. В скалярные колонки Position/Department/DepartmentId пишется основная (первая) запись — их читают документ и ЭДО. Должность больше не обязательное поле формы. Co-Authored-By: Claude Sonnet 5.5 --- .../forms/Vacation/VacationForm.tsx | 24 ++++++---- .../Vacation/sections/RequestSection.tsx | 22 ++------- .../dictionary/employeeOrganizations.test.ts | 28 ++++++++++++ .../requestPortal/shared/dictionary/index.ts | 2 + .../shared/dictionary/organizationLines.ts | 29 ++++++++++++ .../OrganizationField.module.scss | 31 +++++++++++++ .../OrganizationField/OrganizationTable.tsx | 45 +++++++++++++++++++ src/webparts/requestPortal/shared/ui/index.ts | 1 + 8 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dictionary/employeeOrganizations.test.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/organizationLines.ts create mode 100644 src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 0911ea8..d987e62 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -34,7 +34,7 @@ import { } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; -import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils'; import { @@ -109,6 +109,15 @@ const VacationForm: React.FC = ({ }; }, [editId, editListId, notifyCode, setSubmitError]); + // Должность и подразделение больше не вводятся руками: берутся из ПЕРВОЙ (основной) + // записи Employee/ByEmail и пишутся в скалярные колонки Position/Department (их + // читают документ и ЭДО); по каждой организации то же видно в таблице (Organization). + const primaryOrgFields = (records: IEmployeeOrganization[]): Pick => ({ + Position: records[0]?.position || '', + Department: records[0]?.section || '', + DepartmentId: records[0]?.departmentId || '' + }); + // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, // его должность и организации (Employee/ByEmail — см. shared/dictionary/ // employeeOrganizations.service.ts; сотрудник может состоять сразу в @@ -125,8 +134,8 @@ const VacationForm: React.FC = ({ if (!alive) return; setData((prev) => ({ ...prev, - Position: prev.Position || records[0]?.position || prev.Position, - Organization: formatEmployeeOrganizations(records) + ...primaryOrgFields(records), + Organization: formatEmployeeOrganizationsWithDepartment(records) })); }) .catch(() => undefined); @@ -142,15 +151,15 @@ const VacationForm: React.FC = ({ const handleEmployeeChange = (login: string): void => { setData((prev) => ({ ...prev, Employee: login })); if (!login) { - setData((prev) => ({ ...prev, Organization: '' })); + setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' })); return; } getEmployeeOrganizations(context, login) .then((records) => setData((prev) => ({ ...prev, - Position: records[0]?.position || prev.Position, - Organization: formatEmployeeOrganizations(records) + ...primaryOrgFields(records), + Organization: formatEmployeeOrganizationsWithDepartment(records) })) ) .catch(() => undefined); @@ -193,7 +202,6 @@ const VacationForm: React.FC = ({ const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate; const isValid = !!( data.Employee && - data.Position.trim() && data.StartDate && isStartDateValid && Number.isFinite(daysNum) && @@ -203,7 +211,7 @@ const VacationForm: React.FC = ({ if (data.StartDate && !isStartDateValid) { return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; } - if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).'; + if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).'; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; diff --git a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx index 8d7efbf..8c2652e 100644 --- a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; -import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; +import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui'; +import { ProjectPicker } from '../../../shared/dictionary'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { isoDatePlusDays } from '../../../shared/dateUtils'; -/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */ +/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */ export const RequestSection: React.FC = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( @@ -15,21 +15,7 @@ export const RequestSection: React.FC = ({ data, set, context, on placeholder={EMPLOYEE_PLACEHOLDER} /> - - set('Position', v)} /> - - - - { - set('Department', name); - set('DepartmentId', id); - }} - /> - + ): IEmployeeOrganization => ({ + id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p +}); + +describe('организации с подразделением', () => { + it('на каждую организацию своё подразделение', () => { + const text = formatEmployeeOrganizationsWithDepartment([ + rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }), + rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' }) + ]); + expect(text.split('\n').map(parseOrganizationLine)).toEqual([ + { company: 'ТОО А', position: 'Директор', department: 'Дирекция' }, + { company: 'ТОО Б', position: 'Казначей', department: 'Финансы' } + ]); + }); + + it('без подразделения формат прежний «Компания — должность»', () => { + expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор'); + }); + + it('старые строки разбираются с пустым подразделением', () => { + expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' }); + expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' }); + }); +}); diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index bd742f5..727740e 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -28,3 +28,5 @@ export { getCompanyDirectorEmail, getManagerEmail } from './employeeLookup.service'; +export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines'; +export type { IOrganizationLine } from './organizationLines'; diff --git a/src/webparts/requestPortal/shared/dictionary/organizationLines.ts b/src/webparts/requestPortal/shared/dictionary/organizationLines.ts new file mode 100644 index 0000000..c3e9432 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/organizationLines.ts @@ -0,0 +1,29 @@ +import { IEmployeeOrganization } from './models/IEmployeeOrganization'; + +/** + * Организации сотрудника с подразделением: «Компания — должность — подразделение» + * на строку. Нужен формам, что показывают организации таблицей (см. + * OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики + * (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения — + * формат прежний, как у formatEmployeeOrganizations. + */ +export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string { + return records + .map((r) => { + if (r.section) return [r.company, r.position, r.section].join(' — '); + return r.position ? `${r.company} — ${r.position}` : r.company; + }) + .join('\n'); +} + +export interface IOrganizationLine { + company: string; + position: string; + department: string; +} + +/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */ +export function parseOrganizationLine(line: string): IOrganizationLine { + const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim()); + return { company, position, department }; +} diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss index 1d7d945..95938a0 100644 --- a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss @@ -19,3 +19,34 @@ font-size: 13.5px; color: $gray-700; } + +// Таблица организаций: те же цвета, что у .line — «подставлено автоматически». +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-700; + + th, + td { + padding: 9px 13px; + text-align: left; + vertical-align: top; + } + + th { + font-weight: 600; + font-size: 12px; + color: $gray-500; + border-bottom: 1px solid $gray-200; + } + + tr + tr td { + border-top: 1px solid $gray-200; + } +} diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx new file mode 100644 index 0000000..8b6473e --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import styles from './OrganizationField.module.scss'; +import { FormField } from '@tnsu/ui-kit'; +import { parseOrganizationLine } from '../../dictionary/organizationLines'; + +interface IOrganizationTableProps { + /** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */ + value: string; + label?: string; + /** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */ + printIndex?: number; +} + +/** + * Организации сотрудника таблицей «Организация · Должность · Подразделение» — + * read-only, подтягивается из Employee/ByEmail. На каждую организацию своё + * подразделение (сотрудник может состоять в нескольких). Нет данных — ничего не рендерим. + */ +export const OrganizationTable: React.FC = ({ value, label = 'Организация', printIndex }) => { + const rows = value ? value.split('\n').map(parseOrganizationLine) : []; + if (rows.length === 0) return null; + + return ( + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + ))} + +
ОрганизацияДолжностьПодразделение
{r.company}{r.position || '—'}{r.department || '—'}
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 35fa453..8069eb2 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -47,6 +47,7 @@ export { PeopleField } from './PeopleField/PeopleField'; export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; export { PeopleTextField } from './PeopleField/PeopleTextField'; export { OrganizationField } from './OrganizationField/OrganizationField'; +export { OrganizationTable } from './OrganizationField/OrganizationTable'; export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton'; export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead'; export { LinkField } from './LinkField/LinkField';