feat: таблица организаций хранится в списке — три JSON-столбца, видна в просмотре
Tests / frontend-test (pull_request) Successful in 1m11s
Tests / frontend-test (pull_request) Successful in 1m11s
Position, Department, Organization (Заявление на увольнение) теперь хранят JSON-массив строк каждый — компания/департамент/должность по каждой компании сотрудника, один и тот же индекс во всех трёх столбцах = одна запись (model.ts: IOrganizationRecord, zipOrganization/parseOrganizationColumn на чтение, submit.ts: organizationColumn на запись). Раньше Position/Department хранили только ПЕРВУЮ компанию, а Organization — плоский текст без департамента; полную таблицу теперь можно восстановить и показать где угодно. Модель данных формы существенно упростилась: вместо трёх независимых data.Position/Department/Organization + отдельного live-состояния orgRecords — один data.Organization: IOrganizationRecord[], primaryPosition() читает должность основной компании для маршрута и валидации. Загрузка на редактирование больше не ходит в Employee/ByEmail за живыми организациями — таблица целиком восстанавливается из уже сохранённой заявки. OrganizationsTable — теперь ЕДИНСТВЕННЫЙ рендерер таблицы и на форме подачи (редактируемая, editable-проп передан), и в режиме просмотра (read-only, проп не передан) — отдельных read-only полей для просмотра больше нет. createClearance.ts обновлён под новый формат (иначе автосоздание обходного листа читало бы Position/Department как сырой JSON-текст). Легаси: значения, сохранённые ДО перехода на JSON (не валидный JSON), не роняют чтение — трактуются как одна компания (parseOrganizationColumn). Риск: Position/Department — ОБЩИЙ столбец сайта на 8 форм портала, тип Text (лимит SharePoint 255 символов) — менять тип нельзя, задело бы все 8. Явная ошибка при сохранении, если JSON не укладывается (TEXT_COLUMN_LIMIT в submit.ts), вместо тихого обрезания данных. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,12 +25,7 @@ import {
|
|||||||
resolveWriteListId
|
resolveWriteListId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import {
|
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
getEmployeeOrganizations,
|
|
||||||
formatEmployeeOrganizations,
|
|
||||||
getCompanyDirectorEmail,
|
|
||||||
IEmployeeOrganization
|
|
||||||
} from '../../shared/dictionary';
|
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils';
|
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +34,7 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||||
import { loadDismissalRequest } from './load';
|
import { loadDismissalRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -56,10 +51,6 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
// Организации сотрудника СПИСКОМ (не только joined-строка в data.Organization) —
|
|
||||||
// нужны, чтобы «Убрать компанию» (см. handleRemoveOrganization) знало, какие
|
|
||||||
// компании ещё остались, и могло найти в матрице директора именно убираемой.
|
|
||||||
const [orgRecords, setOrgRecords] = useState<IEmployeeOrganization[]>([]);
|
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
@@ -80,6 +71,10 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
// Таблица организаций читается ЦЕЛИКОМ из самой заявки (loaded.data.Organization
|
||||||
|
// — уже собранная зипом трёх столбцов, см. load.ts) — повторный поход в
|
||||||
|
// Employee/ByEmail здесь не нужен: если при подаче убрали компанию, это уже
|
||||||
|
// зафиксировано в сохранённых данных, а не в живом состоянии справочника.
|
||||||
loadDismissalRequest(editId, editListId)
|
loadDismissalRequest(editId, editListId)
|
||||||
.then((loaded) => {
|
.then((loaded) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
@@ -87,22 +82,6 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
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) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
@@ -115,11 +94,15 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId]);
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя, его
|
/** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */
|
||||||
// должность и департамент (Employee/ByEmail — см. shared/dictionary/
|
const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] =>
|
||||||
// employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких
|
records.map((r) => ({ company: r.company, department: r.section, position: r.position }));
|
||||||
// организациях, должность/департамент берём из ПЕРВОЙ записи — см.
|
|
||||||
// OrganizationsTable, там же их можно поправить).
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его
|
||||||
|
// таблицу организаций (Employee/ByEmail — см. shared/dictionary/
|
||||||
|
// employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких).
|
||||||
|
// Должность первой (основной) компании читает маршрут (см. primaryPosition) и
|
||||||
|
// остаётся редактируемой на форме — см. OrganizationsTable.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
return;
|
return;
|
||||||
@@ -130,12 +113,9 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
getEmployeeOrganizations(context, user.email)
|
getEmployeeOrganizations(context, user.email)
|
||||||
.then((records) => {
|
.then((records) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setOrgRecords(records);
|
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: prev.Position || records[0]?.position || prev.Position,
|
Organization: prev.Organization.length > 0 ? prev.Organization : toOrganization(records)
|
||||||
Department: prev.Department || records[0]?.section || prev.Department,
|
|
||||||
Organization: formatEmployeeOrganizations(records)
|
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -145,23 +125,14 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId]);
|
}, [editId]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность, департамент и организации.
|
// Выбор другого сотрудника подтягивает его таблицу организаций.
|
||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
setData((prev) => ({ ...prev, Employee: login, Department: '', Organization: '' }));
|
setData((prev) => ({ ...prev, Employee: login, Organization: [] }));
|
||||||
setOrgRecords([]);
|
|
||||||
if (!login) {
|
if (!login) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getEmployeeOrganizations(context, login)
|
getEmployeeOrganizations(context, login)
|
||||||
.then((records) => {
|
.then((records) => setData((prev) => ({ ...prev, Organization: toOrganization(records) })))
|
||||||
setOrgRecords(records);
|
|
||||||
setData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
Position: records[0]?.position || prev.Position,
|
|
||||||
Department: records[0]?.section || prev.Department,
|
|
||||||
Organization: formatEmployeeOrganizations(records)
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,13 +146,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
: 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(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
`${data.Employee}-${editId ?? 'new'}-${primaryPosition(data.Organization)}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
!!editId || (!!data.Employee && !!requestTypeId),
|
!!editId || (!!data.Employee && !!requestTypeId),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -201,9 +172,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
// компания всё равно убирается из заявления, а несовпадение тихо игнорируем:
|
// компания всё равно убирается из заявления, а несовпадение тихо игнорируем:
|
||||||
// HR в этом случае уберёт лишнего согласующего вручную через саму матрицу.
|
// HR в этом случае уберёт лишнего согласующего вручную через саму матрицу.
|
||||||
const handleRemoveOrganization = (company: string): void => {
|
const handleRemoveOrganization = (company: string): void => {
|
||||||
const remaining = orgRecords.filter((r) => r.company !== company);
|
setData((prev) => ({ ...prev, Organization: prev.Organization.filter((r) => r.company !== company) }));
|
||||||
setOrgRecords(remaining);
|
|
||||||
setData((prev) => ({ ...prev, Organization: formatEmployeeOrganizations(remaining) }));
|
|
||||||
|
|
||||||
getCompanyDirectorEmail(context, company)
|
getCompanyDirectorEmail(context, company)
|
||||||
.then((email) => {
|
.then((email) => {
|
||||||
@@ -219,18 +188,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
// раннюю дату, но проверяем и здесь на случай обхода UI.
|
// раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||||
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS);
|
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS);
|
||||||
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay;
|
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay;
|
||||||
const isValid = !!(
|
const position = primaryPosition(data.Organization);
|
||||||
data.Employee &&
|
const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && isLastDayValid && data.Phone.trim());
|
||||||
data.Position.trim() &&
|
|
||||||
data.LastWorkingDay &&
|
|
||||||
isLastDayValid &&
|
|
||||||
data.Phone.trim()
|
|
||||||
);
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (data.LastWorkingDay && !isLastDayValid) {
|
if (data.LastWorkingDay && !isLastDayValid) {
|
||||||
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`;
|
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 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
||||||
}
|
}
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
@@ -314,7 +278,6 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
set,
|
set,
|
||||||
context,
|
context,
|
||||||
onEmployeeChange: handleEmployeeChange,
|
onEmployeeChange: handleEmployeeChange,
|
||||||
orgRecords,
|
|
||||||
onRemoveOrganization: handleRemoveOrganization
|
onRemoveOrganization: handleRemoveOrganization
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,25 +26,30 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
|
|||||||
## Особое поведение
|
## Особое поведение
|
||||||
- **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» —
|
- **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» —
|
||||||
People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника,
|
People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника,
|
||||||
reloadKey матрицы включает `data.Employee` и `data.Position`.
|
reloadKey матрицы включает `data.Employee` и должность основной компании (см. ниже).
|
||||||
- **Автоподстановка при выборе сотрудника:** должность, департамент и организации —
|
- **Автоподстановка при выборе сотрудника:** вся таблица организаций — из ОДНОГО
|
||||||
всё из ОДНОГО справочника `Employee/ByEmail` (`shared/dictionary`), должность и
|
справочника `Employee/ByEmail` (`shared/dictionary`) — см. «Организация ·
|
||||||
департамент — из ПЕРВОЙ записи (основная компания), чтобы были парой из одной
|
подразделение · должность».
|
||||||
записи. Совместитель с должностями/департаментами, различающимися по компаниям, —
|
|
||||||
см. ниже «Организация · подразделение · должность».
|
|
||||||
- **«Организация · подразделение · должность»** (`OrganizationsTable`,
|
- **«Организация · подразделение · должность»** (`OrganizationsTable`,
|
||||||
`forms/Dismissal/sections/`) — на форме подачи ЕДИНСТВЕННОЕ место для компании,
|
`forms/Dismissal/sections/`) — ЕДИНСТВЕННОЕ место для компании, департамента и
|
||||||
департамента и должности; отдельных полей для них больше нет (были дублированием
|
должности, и на форме подачи (редактируемая), и в режиме просмотра (read-only) —
|
||||||
того же, что уже показывала эта таблица). Компания и департамент по каждой
|
отдельных полей для них нет (были бы дублированием). `data.Organization:
|
||||||
компании — read-only; редактируется только должность ПЕРВОЙ (основной) компании —
|
IOrganizationRecord[]` — массив `{company, department, position}`, компания и
|
||||||
она и есть `data.Position`, используется маршрутом (условие «IsManager») и пишется
|
департамент по каждой строке read-only; редактируется только должность ПЕРВОЙ
|
||||||
в заявку; должности остальных компаний — информационные, не редактируются.
|
(основной) записи — `primaryPosition(data.Organization)`, используется маршрутом
|
||||||
Справочник ничего не вернул (сотрудник не найден в api.tnsu.kz) — таблица всё равно
|
(условие «IsManager») и обязательна валидацией; должности остальных компаний
|
||||||
рендерится одной строкой с прочерками вместо компании/департамента и редактируемой
|
(совместитель) — информационные. Справочник ничего не вернул (сотрудник не найден
|
||||||
должностью: иначе такому человеку нечем было бы заполнить обязательное поле.
|
в api.tnsu.kz) — таблица всё равно рендерится одной строкой с прочерками вместо
|
||||||
В режиме просмотра (`DismissalView`) — не таблица, а простые read-only поля
|
компании/департамента и редактируемой должностью: иначе такому человеку нечем было
|
||||||
«Должность»/«Департамент» + список организаций, как раньше (без live-запроса к
|
бы заполнить обязательное поле.
|
||||||
справочнику: показывается то, что реально сохранено в заявке).
|
|
||||||
|
**Хранение в SharePoint — три ПАРАЛЛЕЛЬНЫХ JSON-столбца**, не один общий блок:
|
||||||
|
`Organization`/`Department`/`Position` — каждый содержит JSON-массив строк
|
||||||
|
(компании / департаменты / должности), один и тот же индекс = одна и та же
|
||||||
|
запись во всех трёх. Сборка при чтении и разборка при записи — `zipOrganization`
|
||||||
|
/ `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка,
|
||||||
|
сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна
|
||||||
|
компания (см. `parseOrganizationColumn`).
|
||||||
- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет,
|
- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет,
|
||||||
свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы.
|
свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы.
|
||||||
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через
|
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через
|
||||||
@@ -67,15 +72,14 @@ Approval-route резолвит маршрут через `HeadType.CompanyDirec
|
|||||||
`TemplateRouteService`). Это уже работает БЕЗ участия портала — сам approval-route
|
`TemplateRouteService`). Это уже работает БЕЗ участия портала — сам approval-route
|
||||||
резолвит все компании сотрудника через `Employee/ByEmail`.
|
резолвит все компании сотрудника через `Employee/ByEmail`.
|
||||||
|
|
||||||
Когда `Employee/ByEmail` вернул больше одной записи, `RequestSection` рендерит
|
`OrganizationsTable` (см. предыдущий раздел) на форме подачи показывает кнопку
|
||||||
`OrganizationsTable` (не read-only `OrganizationField`) — таблицу «Компания —
|
«убрать» у каждой строки, когда компаний больше одной. Если сотрудник реально не
|
||||||
Должность» с кнопкой «убрать» у каждой строки (только на форме подачи, не в
|
увольняется из ВСЕХ компаний сразу, HR убирает лишние: `DismissalForm.handleRemoveOrganization`
|
||||||
просмотре). Если сотрудник реально не увольняется из ВСЕХ компаний сразу, HR
|
|
||||||
убирает лишние: `DismissalForm.handleRemoveOrganization`
|
|
||||||
|
|
||||||
1. снимает компанию из `data.Organization` (сохранённый текст всегда отражает
|
1. снимает запись из `data.Organization` (массив всегда отражает ТЕКУЩИЙ, не
|
||||||
ТЕКУЩИЙ, не первоначальный, набор — так же восстанавливается при повторном
|
первоначальный, набор — так же восстанавливается при повторном открытии
|
||||||
открытии черновика/возврата, см. загрузку `orgRecords` в `DismissalForm`);
|
черновика/возврата: `loadDismissalRequest` читает его из уже сохранённых
|
||||||
|
`Organization`/`Department`/`Position`, без обращения к справочнику заново);
|
||||||
2. независимо резолвит директора/эквивалента этой компании через
|
2. независимо резолвит директора/эквивалента этой компании через
|
||||||
`getCompanyDirectorEmail` (`shared/dictionary/employeeLookup.service.ts` —
|
`getCompanyDirectorEmail` (`shared/dictionary/employeeLookup.service.ts` —
|
||||||
зеркало того же `DirectorPositionByCompany`, что и на бэкенде) и удаляет
|
зеркало того же `DirectorPositionByCompany`, что и на бэкенде) и удаляет
|
||||||
@@ -166,8 +170,8 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
|
|||||||
полей всего восемь, отметок подразделений нет.
|
полей всего восемь, отметок подразделений нет.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
RequestSection (сотрудник · организация/департамент/должность (таблица — форма
|
RequestSection (сотрудник · организация/департамент/должность (таблица —
|
||||||
подачи; должность/департамент/список компаний — просмотр) · самостоятельное
|
редактируемая на форме подачи, read-only в просмотре) · самостоятельное
|
||||||
подразделение · последний рабочий день · телефон · комментарий · ссылка на
|
подразделение · последний рабочий день · телефон · комментарий · ссылка на
|
||||||
обходной лист · лицо, принимающее ОС/НМА)
|
обходной лист · лицо, принимающее ОС/НМА)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { requestLinkUrl } from '../../shared/routing';
|
import { requestLinkUrl } from '../../shared/routing';
|
||||||
import { FORM_ID as CLEARANCE_FORM_ID, EMPTY_DATA as CLEARANCE_EMPTY, IFormData as IClearanceData } from '../Clearance/model';
|
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 { clearanceApprovalService, saveClearanceRequest } from '../Clearance/submit';
|
||||||
import { FORM_ID } from './model';
|
import { FORM_ID, primaryPosition, zipOrganization } from './model';
|
||||||
|
|
||||||
/** Результат автосоздания обходного листа по согласованному заявлению. */
|
/** Результат автосоздания обходного листа по согласованному заявлению. */
|
||||||
export interface IClearanceForDismissal {
|
export interface IClearanceForDismissal {
|
||||||
@@ -104,7 +104,16 @@ export async function ensureClearanceForDismissal(
|
|||||||
if (!employeeEmail) {
|
if (!employeeEmail) {
|
||||||
throw new Error('В заявлении не заполнен сотрудник — обходной лист не создан.');
|
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): партицию спрашиваем прямо сейчас, именно этот вызов
|
// (см. ClearanceForm.save): партицию спрашиваем прямо сейчас, именно этот вызов
|
||||||
@@ -124,10 +133,10 @@ export async function ensureClearanceForDismissal(
|
|||||||
...CLEARANCE_EMPTY,
|
...CLEARANCE_EMPTY,
|
||||||
Employee: employeeEmail,
|
Employee: employeeEmail,
|
||||||
Position: position,
|
Position: position,
|
||||||
Department: (item.Department as string) || '',
|
Department: department,
|
||||||
// Последний рабочий день заявления = дата расторжения договора в обходном листе.
|
// Последний рабочий день заявления = дата расторжения договора в обходном листе.
|
||||||
TerminationDate: ((item.LastWorkingDay as string) || '').slice(0, 10),
|
TerminationDate: ((item.LastWorkingDay as string) || '').slice(0, 10),
|
||||||
Organization: (item.Organization as string) || ''
|
Organization: organizationText
|
||||||
};
|
};
|
||||||
|
|
||||||
// Статус — как при обычной отправке: с согласующими «На согласовании», без них
|
// Статус — как при обычной отправке: с согласующими «На согласовании», без них
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
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 {
|
export interface ILoadedRequest {
|
||||||
data: IFormData;
|
data: IFormData;
|
||||||
@@ -48,13 +48,11 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
|
|||||||
const data: IFormData = {
|
const data: IFormData = {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
Employee: employee?.EMail || '',
|
Employee: employee?.EMail || '',
|
||||||
Position: (item.Position as string) || '',
|
|
||||||
Department: (item.Department as string) || '',
|
|
||||||
StandaloneUnit: (item.StandaloneUnit as string) || '',
|
StandaloneUnit: (item.StandaloneUnit as string) || '',
|
||||||
LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||||
Phone: (item.Phone as string) || '',
|
Phone: (item.Phone as string) || '',
|
||||||
Comment: (item.Comment as string) || '',
|
Comment: (item.Comment as string) || '',
|
||||||
Organization: (item.Organization as string) || '',
|
Organization: zipOrganization(item.Organization, item.Department, item.Position),
|
||||||
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
|
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
|
||||||
ClearanceUrl: (item.ClearanceUrl as { Url?: string } | undefined)?.Url || '',
|
ClearanceUrl: (item.ClearanceUrl as { Url?: string } | undefined)?.Url || '',
|
||||||
AssetsAcceptor: assetsAcceptor?.EMail || ''
|
AssetsAcceptor: assetsAcceptor?.EMail || ''
|
||||||
|
|||||||
@@ -7,6 +7,71 @@ import { RequestTypeId } from '../../models/types';
|
|||||||
*/
|
*/
|
||||||
export const FORM_ID: RequestTypeId = 'hr-dismissal';
|
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»: расторжение трудового договора по
|
* «Шаблон Заявление на увольнение ШТАТ.docx»: расторжение трудового договора по
|
||||||
@@ -21,16 +86,6 @@ export const FORM_ID: RequestTypeId = 'hr-dismissal';
|
|||||||
export interface IFormData {
|
export interface IFormData {
|
||||||
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
||||||
Employee: string;
|
Employee: string;
|
||||||
Position: string;
|
|
||||||
/**
|
|
||||||
* Департамент — поле Section из Employee/ByEmail (справочник api.tnsu.kz),
|
|
||||||
* ПЕРВОЙ компании сотрудника (см. shared/dictionary/employeeOrganizations.
|
|
||||||
* service.ts). Read-only, как раньше со «Структурным подразделением» из
|
|
||||||
* профиля SharePoint — просто источник сменился на 1С/справочник портала.
|
|
||||||
* На форме подачи виден внутри OrganizationsTable (колонка «Департамент» по
|
|
||||||
* каждой компании), отдельного поля для него больше нет.
|
|
||||||
*/
|
|
||||||
Department: string;
|
|
||||||
/** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */
|
/** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */
|
||||||
StandaloneUnit: string;
|
StandaloneUnit: string;
|
||||||
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
|
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
|
||||||
@@ -40,11 +95,15 @@ export interface IFormData {
|
|||||||
/** Необязательный комментарий заявителя. */
|
/** Необязательный комментарий заявителя. */
|
||||||
Comment: string;
|
Comment: string;
|
||||||
/**
|
/**
|
||||||
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
|
* Таблица компаний сотрудника (компания · департамент · должность на
|
||||||
* подтягивается автоматически из Employee/ByEmail при выборе Employee,
|
* каждую) — подтягивается автоматически из Employee/ByEmail при выборе
|
||||||
* read-only (см. shared/ui/OrganizationField).
|
* Employee. Первая запись — основная компания: её должность используется
|
||||||
|
* маршрутом (см. primaryPosition) и остаётся редактируемой (см.
|
||||||
|
* OrganizationsTable); должности/департаменты остальных — только для
|
||||||
|
* просмотра. О ХРАНЕНИИ В СПИСКЕ (три параллельных JSON-массива по
|
||||||
|
* столбцам) — см. IOrganizationRecord.
|
||||||
*/
|
*/
|
||||||
Organization: string;
|
Organization: IOrganizationRecord[];
|
||||||
/**
|
/**
|
||||||
* Ссылка на обходной лист, созданный по этому заявлению — колонка-гиперссылка
|
* Ссылка на обходной лист, созданный по этому заявлению — колонка-гиперссылка
|
||||||
* `ClearanceUrl`. Форма её не пишет (заполняет автосоздание, см.
|
* `ClearanceUrl`. Форма её не пишет (заполняет автосоздание, см.
|
||||||
@@ -80,13 +139,11 @@ export interface ISectionProps {
|
|||||||
|
|
||||||
export const EMPTY_DATA: IFormData = {
|
export const EMPTY_DATA: IFormData = {
|
||||||
Employee: '',
|
Employee: '',
|
||||||
Position: '',
|
|
||||||
Department: '',
|
|
||||||
StandaloneUnit: '',
|
StandaloneUnit: '',
|
||||||
LastWorkingDay: '',
|
LastWorkingDay: '',
|
||||||
Phone: '',
|
Phone: '',
|
||||||
Comment: '',
|
Comment: '',
|
||||||
Organization: '',
|
Organization: [],
|
||||||
ClearanceUrl: '',
|
ClearanceUrl: '',
|
||||||
AssetsAcceptor: ''
|
AssetsAcceptor: ''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormField, Icon, TextInput } from '../../../shared/ui';
|
import { FormField, Icon, TextInput } from '../../../shared/ui';
|
||||||
import { IEmployeeOrganization } from '../../../shared/dictionary';
|
import { IOrganizationRecord } from '../model';
|
||||||
import styles from './OrganizationsTable.module.scss';
|
import styles from './OrganizationsTable.module.scss';
|
||||||
|
|
||||||
interface IOrganizationsTableProps {
|
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), не в режиме просмотра: там состав
|
* DismissalForm.handleRemoveOrganization), не в режиме просмотра: там состав
|
||||||
@@ -24,34 +22,32 @@ interface IOrganizationsTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Организации сотрудника — ЕДИНСТВЕННОЕ место на форме подачи, где видны и
|
* Организации сотрудника — ЕДИНСТВЕННОЕ место для компании/департамента/
|
||||||
* правятся должность и подразделение (раньше были отдельными полями над этой
|
* должности, и на форме подачи (редактируемая — см. onChangePosition/onRemove),
|
||||||
* таблицей — убраны как дублирование, см. историю формы). Интерактивная
|
* и в режиме просмотра заявки (read-only, те же данные из сохранённой заявки,
|
||||||
* таблица, а не список строк (как OrganizationField в режиме просмотра): у
|
* а не live-запрос к справочнику — см. DismissalView). Раньше это были
|
||||||
* сотрудника, оформленного в НЕСКОЛЬКИХ компаниях одновременно, увольнение
|
* отдельные поля над таблицей — убраны как дублирование того, что таблица и
|
||||||
* затрагивает директора/должностного эквивалента КАЖДОЙ из них (маршрут
|
* так показывает.
|
||||||
* `HeadType.CompanyDirectors` в approval-route резолвит их всех). Если реально
|
|
||||||
* уходит только из одной — заявитель убирает лишние компании ЗДЕСЬ, и
|
|
||||||
* соответствующий согласующий убирается из матрицы автоматически (см.
|
|
||||||
* DismissalForm.handleRemoveOrganization) — не нужно отдельно искать его в
|
|
||||||
* матрице и удалять там вручную.
|
|
||||||
*
|
*
|
||||||
* Пустой records (справочник api.tnsu.kz не нашёл сотрудника, см.
|
* Интерактивная таблица, а не список строк: у сотрудника, оформленного в
|
||||||
* employeeOrganizations.service) — не «ничего не показываем», а ОДНА строка с
|
* НЕСКОЛЬКИХ компаниях одновременно, увольнение затрагивает директора/
|
||||||
* прочерками и редактируемой должностью: без этого заявление на человека без
|
* должностного эквивалента КАЖДОЙ из них (маршрут `HeadType.CompanyDirectors`
|
||||||
* карточки в справочнике нельзя было бы подать вовсе (некуда вписать должность
|
* в approval-route резолвит их всех). Если реально уходит только из одной —
|
||||||
* вручную — а маршрут согласования без неё не резолвит условие «IsManager»).
|
* заявитель убирает лишние компании ЗДЕСЬ, и соответствующий согласующий
|
||||||
|
* убирается из матрицы автоматически (см. DismissalForm.handleRemoveOrganization)
|
||||||
|
* — не нужно отдельно искать его в матрице и удалять там вручную.
|
||||||
|
*
|
||||||
|
* Пустой records в РЕДАКТИРУЕМОМ режиме (справочник api.tnsu.kz не нашёл
|
||||||
|
* сотрудника) — не «ничего не показываем», а ОДНА строка с прочерками и
|
||||||
|
* редактируемой должностью: без этого заявление на человека без карточки в
|
||||||
|
* справочнике нельзя было бы подать вовсе (некуда вписать должность вручную —
|
||||||
|
* а маршрут согласования без неё не резолвит условие «IsManager»).
|
||||||
*/
|
*/
|
||||||
export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({
|
export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({ records, onChangePosition, onRemove }) => {
|
||||||
records,
|
|
||||||
position,
|
|
||||||
onChangePosition,
|
|
||||||
onRemove
|
|
||||||
}) => {
|
|
||||||
const rows = records.length > 0 ? records : [undefined];
|
const rows = records.length > 0 ? records : [undefined];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField label="Организация · подразделение · должность" required full>
|
<FormField label="Организация · подразделение · должность" required={!!onChangePosition} full>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -63,12 +59,12 @@ export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<tr key={r?.companyId || r?.company || 'manual'}>
|
<tr key={r?.company || 'manual'}>
|
||||||
<td className={styles.company}>{r?.company || '—'}</td>
|
<td className={styles.company}>{r?.company || '—'}</td>
|
||||||
<td className={styles.department}>{r?.section || '—'}</td>
|
<td className={styles.department}>{r?.department || '—'}</td>
|
||||||
{i === 0 ? (
|
{i === 0 && onChangePosition ? (
|
||||||
<td className={styles.positionInput}>
|
<td className={styles.positionInput}>
|
||||||
<TextInput value={position} onChange={onChangePosition} placeholder="Введите должность" />
|
<TextInput value={r?.position || ''} onChange={onChangePosition} placeholder="Введите должность" />
|
||||||
</td>
|
</td>
|
||||||
) : (
|
) : (
|
||||||
<td className={styles.position}>{r?.position || '—'}</td>
|
<td className={styles.position}>{r?.position || '—'}</td>
|
||||||
|
|||||||
@@ -1,31 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { CollapseSection, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
|
||||||
CollapseSection,
|
|
||||||
FormField,
|
|
||||||
TextInput,
|
|
||||||
TextArea,
|
|
||||||
PhoneInput,
|
|
||||||
PeopleField,
|
|
||||||
OrganizationField,
|
|
||||||
LinkField
|
|
||||||
} from '../../../shared/ui';
|
|
||||||
import { IEmployeeOrganization } from '../../../shared/dictionary';
|
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
|
||||||
import { isoDatePlusMonths } from '../../../shared/dateUtils';
|
import { isoDatePlusMonths } from '../../../shared/dateUtils';
|
||||||
import { OrganizationsTable } from './OrganizationsTable';
|
import { OrganizationsTable } from './OrganizationsTable';
|
||||||
|
|
||||||
interface IRequestSectionProps extends ISectionProps {
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
/**
|
/**
|
||||||
* Организации сотрудника СПИСКОМ (не joined-строкой из data.Organization) —
|
* Убрать компанию — есть только на форме подачи (DismissalForm), делает
|
||||||
* заданы ТОЛЬКО на форме подачи (DismissalForm), где вместо read-only
|
* OrganizationsTable редактируемой (кнопка «убрать» + должность первой
|
||||||
* OrganizationField показывается интерактивная OrganizationsTable: она же
|
* компании). Не задано — DismissalView, таблица read-only.
|
||||||
* теперь единственное место для должности и подразделения (см. её комментарий) —
|
|
||||||
* отдельных полей выше больше нет, дублировать одно и то же дважды незачем.
|
|
||||||
* Не задано (DismissalView, режим просмотра) — секция показывает Должность,
|
|
||||||
* Подразделение и Организацию как раньше, простыми read-only полями.
|
|
||||||
*/
|
*/
|
||||||
orgRecords?: IEmployeeOrganization[];
|
|
||||||
/** Убрать компанию — см. OrganizationsTable. Только форма подачи. */
|
|
||||||
onRemoveOrganization?: (company: string) => void;
|
onRemoveOrganization?: (company: string) => void;
|
||||||
/** Поле «Лицо, принимающее ОС/НМА» редактируемо — см. DismissalView. */
|
/** Поле «Лицо, принимающее ОС/НМА» редактируемо — см. DismissalView. */
|
||||||
assetsAcceptorEditable?: boolean;
|
assetsAcceptorEditable?: boolean;
|
||||||
@@ -41,7 +25,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
set,
|
set,
|
||||||
context,
|
context,
|
||||||
onEmployeeChange,
|
onEmployeeChange,
|
||||||
orgRecords,
|
|
||||||
onRemoveOrganization,
|
onRemoveOrganization,
|
||||||
assetsAcceptorEditable
|
assetsAcceptorEditable
|
||||||
}) => (
|
}) => (
|
||||||
@@ -54,27 +37,24 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{orgRecords ? (
|
{/* ЕДИНСТВЕННОЕ место для компании/департамента/должности — и на форме
|
||||||
// Форма подачи: должность, департамент и компании — только здесь, ниже.
|
подачи (редактируемая, onRemoveOrganization задан), и в просмотре
|
||||||
// Стоит ПЕРЕД «Самостоятельным подразделением» специально: остальные поля
|
(read-only, ничего не передано). Отдельных полей для них нет — были бы
|
||||||
// формы читают уже подставленную должность/компанию, а не наоборот.
|
дублированием того, что уже показывает эта таблица. */}
|
||||||
<OrganizationsTable
|
<OrganizationsTable
|
||||||
records={orgRecords}
|
records={data.Organization}
|
||||||
position={data.Position}
|
onChangePosition={
|
||||||
onChangePosition={(v) => set('Position', v)}
|
onRemoveOrganization &&
|
||||||
onRemove={onRemoveOrganization}
|
((v) =>
|
||||||
/>
|
set(
|
||||||
) : (
|
'Organization',
|
||||||
<>
|
data.Organization.length > 0
|
||||||
<FormField label="Должность" htmlFor="Position">
|
? data.Organization.map((r, i) => (i === 0 ? { ...r, position: v } : r))
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
: [{ company: '', department: '', position: v }]
|
||||||
</FormField>
|
))
|
||||||
<FormField label="Департамент" htmlFor="Department">
|
}
|
||||||
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
|
onRemove={onRemoveOrganization}
|
||||||
</FormField>
|
/>
|
||||||
<OrganizationField value={data.Organization} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
|
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
|
||||||
<TextInput id="StandaloneUnit" value={data.StandaloneUnit} onChange={(v) => set('StandaloneUnit', v)} />
|
<TextInput id="StandaloneUnit" value={data.StandaloneUnit} onChange={(v) => set('StandaloneUnit', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -1,7 +1,43 @@
|
|||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
import { ensureEmployee } from '../../shared/employee';
|
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),
|
Title: buildTitle(employeeName),
|
||||||
RequestStatus: status,
|
RequestStatus: status,
|
||||||
EmployeeId: employeeId,
|
EmployeeId: employeeId,
|
||||||
Position: data.Position || null,
|
// Три параллельных JSON-массива, один индекс — одна компания (см.
|
||||||
Department: data.Department || null,
|
// 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,
|
StandaloneUnit: data.StandaloneUnit || null,
|
||||||
LastWorkingDay: data.LastWorkingDay || null,
|
LastWorkingDay: data.LastWorkingDay || null,
|
||||||
Phone: data.Phone || null,
|
Phone: data.Phone || null,
|
||||||
Comment: data.Comment || null,
|
Comment: data.Comment || null
|
||||||
Organization: data.Organization || null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user