diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index bf64cbd..3748226 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -25,12 +25,7 @@ import { resolveWriteListId } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; -import { - getEmployeeOrganizations, - formatEmployeeOrganizations, - getCompanyDirectorEmail, - IEmployeeOrganization -} from '../../shared/dictionary'; +import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils'; import { @@ -39,7 +34,7 @@ import { dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizationRecord, primaryPosition } from './model'; import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { loadDismissalRequest } from './load'; import { RequestSection } from './sections/RequestSection'; @@ -56,10 +51,6 @@ const DismissalForm: React.FC = ({ const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); - // Организации сотрудника СПИСКОМ (не только joined-строка в data.Organization) — - // нужны, чтобы «Убрать компанию» (см. handleRemoveOrganization) знало, какие - // компании ещё остались, и могло найти в матрице директора именно убираемой. - const [orgRecords, setOrgRecords] = useState([]); const submit = useRequestSubmit(); const readOnly = !isEditableStatus(requestStatus); @@ -80,6 +71,10 @@ const DismissalForm: React.FC = ({ return; } let alive = true; + // Таблица организаций читается ЦЕЛИКОМ из самой заявки (loaded.data.Organization + // — уже собранная зипом трёх столбцов, см. load.ts) — повторный поход в + // Employee/ByEmail здесь не нужен: если при подаче убрали компанию, это уже + // зафиксировано в сохранённых данных, а не в живом состоянии справочника. loadDismissalRequest(editId, editListId) .then((loaded) => { if (alive) { @@ -87,22 +82,6 @@ const DismissalForm: React.FC = ({ setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); } - // Организации сотрудника — теми же, что уже сохранены в заявке: если - // при подаче убрали компанию, она не должна вернуться в таблицу только - // потому, что открыли заявку на редактирование. Сверяем ПОЛНЫЙ список - // из справочника с тем, что осталось в сохранённом data.Organization - // (одна компания на строку, см. formatEmployeeOrganizations), а не - // берём справочник как есть. - const savedCompanies = new Set( - loaded.data.Organization.split('\n') - .filter(Boolean) - .map((line) => line.split(' — ')[0]) - ); - return getEmployeeOrganizations(context, loaded.data.Employee) - .then((records) => { - if (alive) setOrgRecords(records.filter((r) => savedCompanies.has(r.company))); - }) - .catch(() => undefined); }) .catch((e) => { if (alive) { @@ -115,11 +94,15 @@ const DismissalForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId]); - // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, его - // должность и департамент (Employee/ByEmail — см. shared/dictionary/ - // employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких - // организациях, должность/департамент берём из ПЕРВОЙ записи — см. - // OrganizationsTable, там же их можно поправить). + /** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */ + const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] => + records.map((r) => ({ company: r.company, department: r.section, position: r.position })); + + // Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его + // таблицу организаций (Employee/ByEmail — см. shared/dictionary/ + // employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких). + // Должность первой (основной) компании читает маршрут (см. primaryPosition) и + // остаётся редактируемой на форме — см. OrganizationsTable. useEffect(() => { if (editId) { return; @@ -130,12 +113,9 @@ const DismissalForm: React.FC = ({ getEmployeeOrganizations(context, user.email) .then((records) => { if (!alive) return; - setOrgRecords(records); setData((prev) => ({ ...prev, - Position: prev.Position || records[0]?.position || prev.Position, - Department: prev.Department || records[0]?.section || prev.Department, - Organization: formatEmployeeOrganizations(records) + Organization: prev.Organization.length > 0 ? prev.Organization : toOrganization(records) })); }) .catch(() => undefined); @@ -145,23 +125,14 @@ const DismissalForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId]); - // Выбор другого сотрудника подтягивает его должность, департамент и организации. + // Выбор другого сотрудника подтягивает его таблицу организаций. const handleEmployeeChange = (login: string): void => { - setData((prev) => ({ ...prev, Employee: login, Department: '', Organization: '' })); - setOrgRecords([]); + setData((prev) => ({ ...prev, Employee: login, Organization: [] })); if (!login) { return; } getEmployeeOrganizations(context, login) - .then((records) => { - setOrgRecords(records); - setData((prev) => ({ - ...prev, - Position: records[0]?.position || prev.Position, - Department: records[0]?.section || prev.Department, - Organization: formatEmployeeOrganizations(records) - })); - }) + .then((records) => setData((prev) => ({ ...prev, Organization: toOrganization(records) }))) .catch(() => undefined); }; @@ -175,13 +146,13 @@ const DismissalForm: React.FC = ({ editId ? approval.getMatrixForRequest(editId) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [ - { fieldName: 'Position', value: data.Position } + { fieldName: 'Position', value: primaryPosition(data.Organization) } ]), - [context, approval, editId, data.Employee, data.Position, requestTypeId] + [context, approval, editId, data.Employee, data.Organization, requestTypeId] ); const matrix = useMatrixEditor( loadMatrix, - `${data.Employee}-${editId ?? 'new'}-${data.Position}`, + `${data.Employee}-${editId ?? 'new'}-${primaryPosition(data.Organization)}`, !readOnly, !!editId || (!!data.Employee && !!requestTypeId), (login) => approval.getJobTitle(login) @@ -201,9 +172,7 @@ const DismissalForm: React.FC = ({ // компания всё равно убирается из заявления, а несовпадение тихо игнорируем: // HR в этом случае уберёт лишнего согласующего вручную через саму матрицу. const handleRemoveOrganization = (company: string): void => { - const remaining = orgRecords.filter((r) => r.company !== company); - setOrgRecords(remaining); - setData((prev) => ({ ...prev, Organization: formatEmployeeOrganizations(remaining) })); + setData((prev) => ({ ...prev, Organization: prev.Organization.filter((r) => r.company !== company) })); getCompanyDirectorEmail(context, company) .then((email) => { @@ -219,18 +188,13 @@ const DismissalForm: React.FC = ({ // раннюю дату, но проверяем и здесь на случай обхода UI. const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS); const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay; - const isValid = !!( - data.Employee && - data.Position.trim() && - data.LastWorkingDay && - isLastDayValid && - data.Phone.trim() - ); + const position = primaryPosition(data.Organization); + const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && isLastDayValid && data.Phone.trim()); const validate = (): string | undefined => { if (data.LastWorkingDay && !isLastDayValid) { return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`; } - if (!data.Employee || !data.Position.trim() || !data.LastWorkingDay || !data.Phone.trim()) { + if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) { return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.'; } if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; @@ -314,7 +278,6 @@ const DismissalForm: React.FC = ({ set, context, onEmployeeChange: handleEmployeeChange, - orgRecords, onRemoveOrganization: handleRemoveOrganization }; diff --git a/src/webparts/requestPortal/forms/Dismissal/README.md b/src/webparts/requestPortal/forms/Dismissal/README.md index 90cae31..28e3e2d 100644 --- a/src/webparts/requestPortal/forms/Dismissal/README.md +++ b/src/webparts/requestPortal/forms/Dismissal/README.md @@ -26,25 +26,30 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app ## Особое поведение - **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» — People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника, - reloadKey матрицы включает `data.Employee` и `data.Position`. -- **Автоподстановка при выборе сотрудника:** должность, департамент и организации — - всё из ОДНОГО справочника `Employee/ByEmail` (`shared/dictionary`), должность и - департамент — из ПЕРВОЙ записи (основная компания), чтобы были парой из одной - записи. Совместитель с должностями/департаментами, различающимися по компаниям, — - см. ниже «Организация · подразделение · должность». + reloadKey матрицы включает `data.Employee` и должность основной компании (см. ниже). +- **Автоподстановка при выборе сотрудника:** вся таблица организаций — из ОДНОГО + справочника `Employee/ByEmail` (`shared/dictionary`) — см. «Организация · + подразделение · должность». - **«Организация · подразделение · должность»** (`OrganizationsTable`, - `forms/Dismissal/sections/`) — на форме подачи ЕДИНСТВЕННОЕ место для компании, - департамента и должности; отдельных полей для них больше нет (были дублированием - того же, что уже показывала эта таблица). Компания и департамент по каждой - компании — read-only; редактируется только должность ПЕРВОЙ (основной) компании — - она и есть `data.Position`, используется маршрутом (условие «IsManager») и пишется - в заявку; должности остальных компаний — информационные, не редактируются. - Справочник ничего не вернул (сотрудник не найден в api.tnsu.kz) — таблица всё равно - рендерится одной строкой с прочерками вместо компании/департамента и редактируемой - должностью: иначе такому человеку нечем было бы заполнить обязательное поле. - В режиме просмотра (`DismissalView`) — не таблица, а простые read-only поля - «Должность»/«Департамент» + список организаций, как раньше (без live-запроса к - справочнику: показывается то, что реально сохранено в заявке). + `forms/Dismissal/sections/`) — ЕДИНСТВЕННОЕ место для компании, департамента и + должности, и на форме подачи (редактируемая), и в режиме просмотра (read-only) — + отдельных полей для них нет (были бы дублированием). `data.Organization: + IOrganizationRecord[]` — массив `{company, department, position}`, компания и + департамент по каждой строке read-only; редактируется только должность ПЕРВОЙ + (основной) записи — `primaryPosition(data.Organization)`, используется маршрутом + (условие «IsManager») и обязательна валидацией; должности остальных компаний + (совместитель) — информационные. Справочник ничего не вернул (сотрудник не найден + в api.tnsu.kz) — таблица всё равно рендерится одной строкой с прочерками вместо + компании/департамента и редактируемой должностью: иначе такому человеку нечем было + бы заполнить обязательное поле. + + **Хранение в SharePoint — три ПАРАЛЛЕЛЬНЫХ JSON-столбца**, не один общий блок: + `Organization`/`Department`/`Position` — каждый содержит JSON-массив строк + (компании / департаменты / должности), один и тот же индекс = одна и та же + запись во всех трёх. Сборка при чтении и разборка при записи — `zipOrganization` + / `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка, + сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна + компания (см. `parseOrganizationColumn`). - **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет, свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы. - **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через @@ -67,15 +72,14 @@ Approval-route резолвит маршрут через `HeadType.CompanyDirec `TemplateRouteService`). Это уже работает БЕЗ участия портала — сам approval-route резолвит все компании сотрудника через `Employee/ByEmail`. -Когда `Employee/ByEmail` вернул больше одной записи, `RequestSection` рендерит -`OrganizationsTable` (не read-only `OrganizationField`) — таблицу «Компания — -Должность» с кнопкой «убрать» у каждой строки (только на форме подачи, не в -просмотре). Если сотрудник реально не увольняется из ВСЕХ компаний сразу, HR -убирает лишние: `DismissalForm.handleRemoveOrganization` +`OrganizationsTable` (см. предыдущий раздел) на форме подачи показывает кнопку +«убрать» у каждой строки, когда компаний больше одной. Если сотрудник реально не +увольняется из ВСЕХ компаний сразу, HR убирает лишние: `DismissalForm.handleRemoveOrganization` -1. снимает компанию из `data.Organization` (сохранённый текст всегда отражает - ТЕКУЩИЙ, не первоначальный, набор — так же восстанавливается при повторном - открытии черновика/возврата, см. загрузку `orgRecords` в `DismissalForm`); +1. снимает запись из `data.Organization` (массив всегда отражает ТЕКУЩИЙ, не + первоначальный, набор — так же восстанавливается при повторном открытии + черновика/возврата: `loadDismissalRequest` читает его из уже сохранённых + `Organization`/`Department`/`Position`, без обращения к справочнику заново); 2. независимо резолвит директора/эквивалента этой компании через `getCompanyDirectorEmail` (`shared/dictionary/employeeLookup.service.ts` — зеркало того же `DirectorPositionByCompany`, что и на бэкенде) и удаляет @@ -166,8 +170,8 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и полей всего восемь, отметок подразделений нет. ## Секции формы -RequestSection (сотрудник · организация/департамент/должность (таблица — форма -подачи; должность/департамент/список компаний — просмотр) · самостоятельное +RequestSection (сотрудник · организация/департамент/должность (таблица — +редактируемая на форме подачи, read-only в просмотре) · самостоятельное подразделение · последний рабочий день · телефон · комментарий · ссылка на обходной лист · лицо, принимающее ОС/НМА) diff --git a/src/webparts/requestPortal/forms/Dismissal/createClearance.ts b/src/webparts/requestPortal/forms/Dismissal/createClearance.ts index 6783635..c51dea6 100644 --- a/src/webparts/requestPortal/forms/Dismissal/createClearance.ts +++ b/src/webparts/requestPortal/forms/Dismissal/createClearance.ts @@ -18,7 +18,7 @@ import { import { requestLinkUrl } from '../../shared/routing'; import { FORM_ID as CLEARANCE_FORM_ID, EMPTY_DATA as CLEARANCE_EMPTY, IFormData as IClearanceData } from '../Clearance/model'; import { clearanceApprovalService, saveClearanceRequest } from '../Clearance/submit'; -import { FORM_ID } from './model'; +import { FORM_ID, primaryPosition, zipOrganization } from './model'; /** Результат автосоздания обходного листа по согласованному заявлению. */ export interface IClearanceForDismissal { @@ -104,7 +104,16 @@ export async function ensureClearanceForDismissal( if (!employeeEmail) { throw new Error('В заявлении не заполнен сотрудник — обходной лист не создан.'); } - const position = (item.Position as string) || ''; + // Три параллельных JSON-столбца (см. IOrganizationRecord) → таблица компаний; + // обходному листу нужна только ОСНОВНАЯ (первая) — должность для условия + // «IsManager» и подразделение для карточки, плюс joined-текст для его + // собственного read-only поля Organization (другой формат, см. Clearance/model.ts). + const organization = zipOrganization(item.Organization, item.Department, item.Position); + const position = primaryPosition(organization); + const department = organization[0]?.department || ''; + const organizationText = organization + .map((r) => (r.position ? `${r.company} — ${r.position}` : r.company)) + .join('\n'); // Тип заявки и партиция для записи — как у обычной подачи обходного листа // (см. ClearanceForm.save): партицию спрашиваем прямо сейчас, именно этот вызов @@ -124,10 +133,10 @@ export async function ensureClearanceForDismissal( ...CLEARANCE_EMPTY, Employee: employeeEmail, Position: position, - Department: (item.Department as string) || '', + Department: department, // Последний рабочий день заявления = дата расторжения договора в обходном листе. TerminationDate: ((item.LastWorkingDay as string) || '').slice(0, 10), - Organization: (item.Organization as string) || '' + Organization: organizationText }; // Статус — как при обычной отправке: с согласующими «На согласовании», без них diff --git a/src/webparts/requestPortal/forms/Dismissal/load.ts b/src/webparts/requestPortal/forms/Dismissal/load.ts index 6a36cae..66b0e66 100644 --- a/src/webparts/requestPortal/forms/Dismissal/load.ts +++ b/src/webparts/requestPortal/forms/Dismissal/load.ts @@ -1,6 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, zipOrganization } from './model'; export interface ILoadedRequest { data: IFormData; @@ -48,13 +48,11 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro const data: IFormData = { ...EMPTY_DATA, Employee: employee?.EMail || '', - Position: (item.Position as string) || '', - Department: (item.Department as string) || '', StandaloneUnit: (item.StandaloneUnit as string) || '', LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date Phone: (item.Phone as string) || '', Comment: (item.Comment as string) || '', - Organization: (item.Organization as string) || '', + Organization: zipOrganization(item.Organization, item.Department, item.Position), // Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес. ClearanceUrl: (item.ClearanceUrl as { Url?: string } | undefined)?.Url || '', AssetsAcceptor: assetsAcceptor?.EMail || '' diff --git a/src/webparts/requestPortal/forms/Dismissal/model.ts b/src/webparts/requestPortal/forms/Dismissal/model.ts index 91b1e99..f17aadd 100644 --- a/src/webparts/requestPortal/forms/Dismissal/model.ts +++ b/src/webparts/requestPortal/forms/Dismissal/model.ts @@ -7,6 +7,71 @@ import { RequestTypeId } from '../../models/types'; */ export const FORM_ID: RequestTypeId = 'hr-dismissal'; +/** + * Одна строка таблицы организаций (см. forms/Dismissal/sections/ + * OrganizationsTable.tsx) — компания + департамент + должность СОТРУДНИКА В + * ЭТОЙ компании. + * + * В списке SharePoint компания/департамент/должность хранятся КАЖДАЯ В СВОЁМ + * столбце (`Organization`/`Department`/`Position` — три Note-колонки), но КАК + * JSON-МАССИВ строк в каждой, а не как раньше — плоским значением/joined-строкой: + * `Organization: '["ТОО A","ТОО B"]'`, `Department: '["Служба X","Служба Y"]'`, + * `Position: '["Начальник","Инженер"]'` — три ПАРАЛЛЕЛЬНЫХ массива одной длины, + * один индекс = одна компания. IFormData/приложение работают с ними уже + * СОБРАННЫМИ в этот единый тип — сборка (load.ts, zipOrganization) и разборка + * по трём столбцам (submit.ts, splitOrganization) — на границе с SharePoint, + * остальной код таблицу как массив строк никогда не видит. + * + * Именно эта структура и позволяет показать таблицу целиком в режиме просмотра + * заявки (DismissalView) — раньше Position/Department хранили только ПЕРВУЮ + * компанию, а Organization — плоский текст без департамента. + */ +export interface IOrganizationRecord { + company: string; + department: string; + position: string; +} + +/** Должность ОСНОВНОЙ (первой) компании — читает маршрут согласования (условие «IsManager»), требуется валидацией. '' — компания не выбрана/справочник пуст. */ +export function primaryPosition(organization: IOrganizationRecord[]): string { + return organization[0]?.position || ''; +} + +/** + * Разобрать один из трёх параллельных столбцов (Organization/Department/ + * Position, см. IOrganizationRecord) — JSON-массив строк. Не валидный JSON — + * заявка сохранена ДО перехода на этот формат (столбец был скалярным Text/ + * joined-строкой): не роняем чтение, трактуем как ОДНУ компанию — единственное, + * что старый формат вообще мог хранить (split(' — ')[0] на однострочном + * скаляре — no-op, поэтому одна функция годится и для Position/Department, и + * для легаси Organization вида «Компания — Должность» построчно). + */ +export function parseOrganizationColumn(raw: unknown): string[] { + if (typeof raw !== 'string' || raw.trim() === '') return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; + } catch { + return raw.split('\n').map((line) => line.split(' — ')[0]); + } +} + +/** Собрать таблицу организаций из трёх столбцов — см. IOrganizationRecord. */ +export function zipOrganization( + companiesRaw: unknown, + departmentsRaw: unknown, + positionsRaw: unknown +): IOrganizationRecord[] { + const companies = parseOrganizationColumn(companiesRaw); + const departments = parseOrganizationColumn(departmentsRaw); + const positions = parseOrganizationColumn(positionsRaw); + return companies.map((company, i) => ({ + company, + department: departments[i] || '', + position: positions[i] || '' + })); +} + /** * Данные формы «Заявление на увольнение» (штатный сотрудник) по бумажному шаблону * «Шаблон Заявление на увольнение ШТАТ.docx»: расторжение трудового договора по @@ -21,16 +86,6 @@ export const FORM_ID: RequestTypeId = 'hr-dismissal'; export interface IFormData { /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ Employee: string; - Position: string; - /** - * Департамент — поле Section из Employee/ByEmail (справочник api.tnsu.kz), - * ПЕРВОЙ компании сотрудника (см. shared/dictionary/employeeOrganizations. - * service.ts). Read-only, как раньше со «Структурным подразделением» из - * профиля SharePoint — просто источник сменился на 1С/справочник портала. - * На форме подачи виден внутри OrganizationsTable (колонка «Департамент» по - * каждой компании), отдельного поля для него больше нет. - */ - Department: string; /** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */ StandaloneUnit: string; /** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */ @@ -40,11 +95,15 @@ export interface IFormData { /** Необязательный комментарий заявителя. */ Comment: string; /** - * Организации сотрудника (`\n`-joined «Компания — должность» на строку) — - * подтягивается автоматически из Employee/ByEmail при выборе Employee, - * read-only (см. shared/ui/OrganizationField). + * Таблица компаний сотрудника (компания · департамент · должность на + * каждую) — подтягивается автоматически из Employee/ByEmail при выборе + * Employee. Первая запись — основная компания: её должность используется + * маршрутом (см. primaryPosition) и остаётся редактируемой (см. + * OrganizationsTable); должности/департаменты остальных — только для + * просмотра. О ХРАНЕНИИ В СПИСКЕ (три параллельных JSON-массива по + * столбцам) — см. IOrganizationRecord. */ - Organization: string; + Organization: IOrganizationRecord[]; /** * Ссылка на обходной лист, созданный по этому заявлению — колонка-гиперссылка * `ClearanceUrl`. Форма её не пишет (заполняет автосоздание, см. @@ -80,13 +139,11 @@ export interface ISectionProps { export const EMPTY_DATA: IFormData = { Employee: '', - Position: '', - Department: '', StandaloneUnit: '', LastWorkingDay: '', Phone: '', Comment: '', - Organization: '', + Organization: [], ClearanceUrl: '', AssetsAcceptor: '' }; diff --git a/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx b/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx index 718f55f..dbaeaeb 100644 --- a/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx @@ -1,20 +1,18 @@ import * as React from 'react'; import { FormField, Icon, TextInput } from '../../../shared/ui'; -import { IEmployeeOrganization } from '../../../shared/dictionary'; +import { IOrganizationRecord } from '../model'; import styles from './OrganizationsTable.module.scss'; interface IOrganizationsTableProps { - records: IEmployeeOrganization[]; + records: IOrganizationRecord[]; /** - * Должность из ПЕРВОЙ (основной) компании — сюда же пишет форма (data.Position), - * единственная ячейка «Должность», которая реально редактируется. У остальных - * компаний (если сотрудник — совместитель) своя должность показывается как - * есть в справочнике, но не редактируется: маршрут согласования резолвится по - * ОДНОЙ должности основной компании (см. DismissalForm — fieldValues.Position), - * ставить в соответствие ей должности других компаний незачем. + * Должность ПЕРВОЙ (основной) компании редактируема — единственная ячейка + * таблицы, у которой это так: маршрут согласования резолвится по НЕЙ (см. + * DismissalForm — fieldValues.Position), должности остальных компаний + * (совместитель) — только для просмотра, ставить им что-то в соответствие + * незачем. Не задано — вся таблица read-only (режим просмотра, DismissalView). */ - position: string; - onChangePosition: (value: string) => void; + onChangePosition?: (value: string) => void; /** * Убрать компанию из заявления — есть только на форме подачи (см. * DismissalForm.handleRemoveOrganization), не в режиме просмотра: там состав @@ -24,34 +22,32 @@ interface IOrganizationsTableProps { } /** - * Организации сотрудника — ЕДИНСТВЕННОЕ место на форме подачи, где видны и - * правятся должность и подразделение (раньше были отдельными полями над этой - * таблицей — убраны как дублирование, см. историю формы). Интерактивная - * таблица, а не список строк (как OrganizationField в режиме просмотра): у - * сотрудника, оформленного в НЕСКОЛЬКИХ компаниях одновременно, увольнение - * затрагивает директора/должностного эквивалента КАЖДОЙ из них (маршрут - * `HeadType.CompanyDirectors` в approval-route резолвит их всех). Если реально - * уходит только из одной — заявитель убирает лишние компании ЗДЕСЬ, и - * соответствующий согласующий убирается из матрицы автоматически (см. - * DismissalForm.handleRemoveOrganization) — не нужно отдельно искать его в - * матрице и удалять там вручную. + * Организации сотрудника — ЕДИНСТВЕННОЕ место для компании/департамента/ + * должности, и на форме подачи (редактируемая — см. onChangePosition/onRemove), + * и в режиме просмотра заявки (read-only, те же данные из сохранённой заявки, + * а не live-запрос к справочнику — см. DismissalView). Раньше это были + * отдельные поля над таблицей — убраны как дублирование того, что таблица и + * так показывает. * - * Пустой records (справочник api.tnsu.kz не нашёл сотрудника, см. - * employeeOrganizations.service) — не «ничего не показываем», а ОДНА строка с - * прочерками и редактируемой должностью: без этого заявление на человека без - * карточки в справочнике нельзя было бы подать вовсе (некуда вписать должность - * вручную — а маршрут согласования без неё не резолвит условие «IsManager»). + * Интерактивная таблица, а не список строк: у сотрудника, оформленного в + * НЕСКОЛЬКИХ компаниях одновременно, увольнение затрагивает директора/ + * должностного эквивалента КАЖДОЙ из них (маршрут `HeadType.CompanyDirectors` + * в approval-route резолвит их всех). Если реально уходит только из одной — + * заявитель убирает лишние компании ЗДЕСЬ, и соответствующий согласующий + * убирается из матрицы автоматически (см. DismissalForm.handleRemoveOrganization) + * — не нужно отдельно искать его в матрице и удалять там вручную. + * + * Пустой records в РЕДАКТИРУЕМОМ режиме (справочник api.tnsu.kz не нашёл + * сотрудника) — не «ничего не показываем», а ОДНА строка с прочерками и + * редактируемой должностью: без этого заявление на человека без карточки в + * справочнике нельзя было бы подать вовсе (некуда вписать должность вручную — + * а маршрут согласования без неё не резолвит условие «IsManager»). */ -export const OrganizationsTable: React.FC = ({ - records, - position, - onChangePosition, - onRemove -}) => { +export const OrganizationsTable: React.FC = ({ records, onChangePosition, onRemove }) => { const rows = records.length > 0 ? records : [undefined]; return ( - + @@ -63,12 +59,12 @@ export const OrganizationsTable: React.FC = ({ {rows.map((r, i) => ( - + - - {i === 0 ? ( + + {i === 0 && onChangePosition ? ( ) : ( diff --git a/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx index 6281066..b7186c3 100644 --- a/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx @@ -1,31 +1,15 @@ import * as React from 'react'; -import { - CollapseSection, - FormField, - TextInput, - TextArea, - PhoneInput, - PeopleField, - OrganizationField, - LinkField -} from '../../../shared/ui'; -import { IEmployeeOrganization } from '../../../shared/dictionary'; +import { CollapseSection, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model'; import { isoDatePlusMonths } from '../../../shared/dateUtils'; import { OrganizationsTable } from './OrganizationsTable'; interface IRequestSectionProps extends ISectionProps { /** - * Организации сотрудника СПИСКОМ (не joined-строкой из data.Organization) — - * заданы ТОЛЬКО на форме подачи (DismissalForm), где вместо read-only - * OrganizationField показывается интерактивная OrganizationsTable: она же - * теперь единственное место для должности и подразделения (см. её комментарий) — - * отдельных полей выше больше нет, дублировать одно и то же дважды незачем. - * Не задано (DismissalView, режим просмотра) — секция показывает Должность, - * Подразделение и Организацию как раньше, простыми read-only полями. + * Убрать компанию — есть только на форме подачи (DismissalForm), делает + * OrganizationsTable редактируемой (кнопка «убрать» + должность первой + * компании). Не задано — DismissalView, таблица read-only. */ - orgRecords?: IEmployeeOrganization[]; - /** Убрать компанию — см. OrganizationsTable. Только форма подачи. */ onRemoveOrganization?: (company: string) => void; /** Поле «Лицо, принимающее ОС/НМА» редактируемо — см. DismissalView. */ assetsAcceptorEditable?: boolean; @@ -41,7 +25,6 @@ export const RequestSection: React.FC = ({ set, context, onEmployeeChange, - orgRecords, onRemoveOrganization, assetsAcceptorEditable }) => ( @@ -54,27 +37,24 @@ export const RequestSection: React.FC = ({ placeholder={EMPLOYEE_PLACEHOLDER} /> - {orgRecords ? ( - // Форма подачи: должность, департамент и компании — только здесь, ниже. - // Стоит ПЕРЕД «Самостоятельным подразделением» специально: остальные поля - // формы читают уже подставленную должность/компанию, а не наоборот. - set('Position', v)} - onRemove={onRemoveOrganization} - /> - ) : ( - <> - - set('Position', v)} /> - - - set('Department', v)} /> - - - - )} + {/* ЕДИНСТВЕННОЕ место для компании/департамента/должности — и на форме + подачи (редактируемая, onRemoveOrganization задан), и в просмотре + (read-only, ничего не передано). Отдельных полей для них нет — были бы + дублированием того, что уже показывает эта таблица. */} + + set( + 'Organization', + data.Organization.length > 0 + ? data.Organization.map((r, i) => (i === 0 ? { ...r, position: v } : r)) + : [{ company: '', department: '', position: v }] + )) + } + onRemove={onRemoveOrganization} + /> set('StandaloneUnit', v)} /> diff --git a/src/webparts/requestPortal/forms/Dismissal/submit.ts b/src/webparts/requestPortal/forms/Dismissal/submit.ts index 4d3d45f..a13b824 100644 --- a/src/webparts/requestPortal/forms/Dismissal/submit.ts +++ b/src/webparts/requestPortal/forms/Dismissal/submit.ts @@ -1,7 +1,43 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { ensureEmployee } from '../../shared/employee'; -import { FORM_ID, IFormData } from './model'; +import { FORM_ID, IFormData, IOrganizationRecord } from './model'; + +/** + * Position/Department — ОБЩИЙ столбец сайта (эталон) для 8 форм портала, тип + * Text (однострочный) — SharePoint не позволяет там больше 255 символов, и + * перегрузка типа отсюда невозможна и не нужна: столбец общий, менять его тип + * значило бы менять его для всех 8 форм разом. Organization уже Note + * (многострочный, без этого лимита) — предел касается только двух из трёх. + * У реального совместителя (2-3 компании) JSON обычно укладывается на порядок + * ниже 255, но если однажды не уложится — лучше явная ошибка при сохранении, + * чем молча обрезанная запись (SharePoint либо обрежет остаток, либо вернёт + * ошибку на самом write — в обоих случаях тихо терять данные хуже). + */ +const TEXT_COLUMN_LIMIT = 255; + +/** + * Один из трёх параллельных столбцов таблицы организаций (см. + * IOrganizationRecord) — JSON-массив значений по каждой компании, в том же + * порядке для всех трёх столбцов. Пустая таблица → null (а не '[]'), как и + * остальные необязательные поля здесь. + */ +function organizationColumn( + records: IOrganizationRecord[], + pick: (r: IOrganizationRecord) => string, + columnTitle: string, + limit?: number + // eslint-disable-next-line @rushstack/no-new-null -- SharePoint REST ожидает null, чтобы очистить поле +): string | null { + if (records.length === 0) return null; + const json = JSON.stringify(records.map(pick)); + if (limit !== undefined && json.length > limit) { + throw new Error( + `Слишком много компаний для сохранения (столбец «${columnTitle}»: ${json.length} символов, лимит ${limit}) — уберите лишние в таблице «Организация».` + ); + } + return json; +} /** * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над @@ -43,13 +79,16 @@ function buildItem( Title: buildTitle(employeeName), RequestStatus: status, EmployeeId: employeeId, - Position: data.Position || null, - Department: data.Department || null, + // Три параллельных JSON-массива, один индекс — одна компания (см. + // IOrganizationRecord) — отдельные столбцы, а не общий блок. Лимит только + // на Position/Department (Text, см. TEXT_COLUMN_LIMIT) — Organization это Note. + Position: organizationColumn(data.Organization, (r) => r.position, 'Должность', TEXT_COLUMN_LIMIT), + Department: organizationColumn(data.Organization, (r) => r.department, 'Департамент', TEXT_COLUMN_LIMIT), + Organization: organizationColumn(data.Organization, (r) => r.company, 'Организация'), StandaloneUnit: data.StandaloneUnit || null, LastWorkingDay: data.LastWorkingDay || null, Phone: data.Phone || null, - Comment: data.Comment || null, - Organization: data.Organization || null + Comment: data.Comment || null }; }
{r?.company || '—'}{r?.section || '—'}{r?.department || '—'} - + {r?.position || '—'}