feat: департамент в таблице организаций, минимум месяц до увольнения
Заявление на увольнение: 1. Последний рабочий день — минимум через MIN_NOTICE_MONTHS (1) КАЛЕНДАРНЫЙ месяц от сегодня (новый isoDatePlusMonths в shared/dateUtils.ts, а не приближение через 30 дней — setMonth сам разносит месяцы разной длины). 2. Департамент (Section из Employee/ByEmail) — колонка в OrganizationsTable вместо отдельного профильного поля: источник сменился со SharePoint-профиля (SPS-Department) на тот же справочник api.tnsu.kz, что уже даёт компанию и должность. Заодно убраны отдельные «Должность»/«Структурное подразделение» над таблицей — дублировали то, что таблица и так показывает. Редактируется по-прежнему только должность ПЕРВОЙ (основной) компании — она и есть data.Position, читает её маршрут; должности остальных компаний и департамент — информационные. Пустой справочник (сотрудник не найден) — таблица не исчезает, а показывает одну строку с прочерками и редактируемой должностью, иначе заявление на такого человека нельзя было бы подать. Режим просмотра (DismissalView) не тронут — там по-прежнему простые read-only поля, без live-запроса к справочнику. На тенанте: Title колонки Department синхронизирован под «Департамент» — ТОЛЬКО на списке «Заявление на увольнение», списочным override, а не через общий столбец сайта. Через -UpdateExistingLists один раз случайно затёрло Department и на «Обходном листе», и на «Заявке в 1С» (internalName у них общий, Title разный) — заметил и починил в этой же сессии; см. memory provisioning-shared-site-columns для деталей на будущее. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,14 +32,14 @@ import {
|
|||||||
IEmployeeOrganization
|
IEmployeeOrganization
|
||||||
} from '../../shared/dictionary';
|
} from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS } 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';
|
||||||
@@ -116,9 +116,10 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
}, [editId, editListId]);
|
}, [editId, editListId]);
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя, его
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя, его
|
||||||
// должность и организации (Employee/ByEmail — см. shared/dictionary/
|
// должность и департамент (Employee/ByEmail — см. shared/dictionary/
|
||||||
// employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких
|
// employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких
|
||||||
// организациях, должность берём из ПЕРВОЙ записи), подразделение — из профиля.
|
// организациях, должность/департамент берём из ПЕРВОЙ записи — см.
|
||||||
|
// OrganizationsTable, там же их можно поправить).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
return;
|
return;
|
||||||
@@ -133,40 +134,31 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: prev.Position || records[0]?.position || prev.Position,
|
Position: prev.Position || records[0]?.position || prev.Position,
|
||||||
|
Department: prev.Department || records[0]?.section || prev.Department,
|
||||||
Organization: formatEmployeeOrganizations(records)
|
Organization: formatEmployeeOrganizations(records)
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
approval
|
|
||||||
.getProfileFields(user.email)
|
|
||||||
.then((profile) => {
|
|
||||||
if (!alive) return;
|
|
||||||
setData((prev) => ({ ...prev, Department: prev.Department || profile.department }));
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// 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, Department: '', Organization: '' }));
|
||||||
setOrgRecords([]);
|
setOrgRecords([]);
|
||||||
if (!login) {
|
if (!login) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
approval
|
|
||||||
.getProfileFields(login)
|
|
||||||
.then((profile) => setData((prev) => ({ ...prev, Department: profile.department })))
|
|
||||||
.catch(() => undefined);
|
|
||||||
getEmployeeOrganizations(context, login)
|
getEmployeeOrganizations(context, login)
|
||||||
.then((records) => {
|
.then((records) => {
|
||||||
setOrgRecords(records);
|
setOrgRecords(records);
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: records[0]?.position || prev.Position,
|
Position: records[0]?.position || prev.Position,
|
||||||
|
Department: records[0]?.section || prev.Department,
|
||||||
Organization: formatEmployeeOrganizations(records)
|
Organization: formatEmployeeOrganizations(records)
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
@@ -222,11 +214,11 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Последний рабочий день не может быть в прошлом: заявление подаётся заранее.
|
// Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц —
|
||||||
// Календарь (min у input type=date, см. RequestSection) не даёт выбрать более
|
// календарь (min у input type=date, см. RequestSection) не даёт выбрать более
|
||||||
// раннюю дату, но проверяем и здесь на случай обхода UI.
|
// раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||||
const today = isoDatePlusDays(0);
|
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS);
|
||||||
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= today;
|
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay;
|
||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
data.Position.trim() &&
|
||||||
@@ -236,7 +228,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
);
|
);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (data.LastWorkingDay && !isLastDayValid) {
|
if (data.LastWorkingDay && !isLastDayValid) {
|
||||||
return `Последний рабочий день не может быть раньше сегодняшней даты (${formatIsoDate(today)}).`;
|
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`;
|
||||||
}
|
}
|
||||||
if (!data.Employee || !data.Position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
|
if (!data.Employee || !data.Position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
|
||||||
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
||||||
|
|||||||
@@ -27,17 +27,31 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
|
|||||||
- **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» —
|
- **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» —
|
||||||
People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника,
|
People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника,
|
||||||
reloadKey матрицы включает `data.Employee` и `data.Position`.
|
reloadKey матрицы включает `data.Employee` и `data.Position`.
|
||||||
- **Автоподстановка при выборе сотрудника:** должность и организации — из справочника
|
- **Автоподстановка при выборе сотрудника:** должность, департамент и организации —
|
||||||
`Employee/ByEmail` (`shared/dictionary`, должность из ПЕРВОЙ записи, чтобы должность
|
всё из ОДНОГО справочника `Employee/ByEmail` (`shared/dictionary`), должность и
|
||||||
и организация были парой из одной записи); структурное подразделение — из профиля
|
департамент — из ПЕРВОЙ записи (основная компания), чтобы были парой из одной
|
||||||
SharePoint (`SPS-Department` через `approval.getProfileFields`).
|
записи. Совместитель с должностями/департаментами, различающимися по компаниям, —
|
||||||
- **Структурное подразделение** (`Department`) — read-only, только из профиля: чтобы
|
см. ниже «Организация · подразделение · должность».
|
||||||
не расходиться с профилем (как в «Обходном листе»). **Самостоятельное
|
- **«Организация · подразделение · должность»** (`OrganizationsTable`,
|
||||||
подразделение** (`StandaloneUnit`) в профиле не хранится — свободный ввод.
|
`forms/Dismissal/sections/`) — на форме подачи ЕДИНСТВЕННОЕ место для компании,
|
||||||
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не может быть в прошлом
|
департамента и должности; отдельных полей для них больше нет (были дублированием
|
||||||
(`min` у `input type=date` + проверка в `validate` на случай обхода UI).
|
того же, что уже показывала эта таблица). Компания и департамент по каждой
|
||||||
Срок предупреждения об увольнении (обычно месяц) **не** проверяется — договорную
|
компании — read-only; редактируется только должность ПЕРВОЙ (основной) компании —
|
||||||
дату согласуют с работодателем, форма её только фиксирует.
|
она и есть `data.Position`, используется маршрутом (условие «IsManager») и пишется
|
||||||
|
в заявку; должности остальных компаний — информационные, не редактируются.
|
||||||
|
Справочник ничего не вернул (сотрудник не найден в api.tnsu.kz) — таблица всё равно
|
||||||
|
рендерится одной строкой с прочерками вместо компании/департамента и редактируемой
|
||||||
|
должностью: иначе такому человеку нечем было бы заполнить обязательное поле.
|
||||||
|
В режиме просмотра (`DismissalView`) — не таблица, а простые read-only поля
|
||||||
|
«Должность»/«Департамент» + список организаций, как раньше (без live-запроса к
|
||||||
|
справочнику: показывается то, что реально сохранено в заявке).
|
||||||
|
- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет,
|
||||||
|
свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы.
|
||||||
|
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через
|
||||||
|
`MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`,
|
||||||
|
`min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок
|
||||||
|
предупреждения об увольнении по ТК. Более позднюю дату (по договорённости с
|
||||||
|
работодателем) форма не ограничивает.
|
||||||
- **Контактный телефон** (`Phone`) — обязателен, `PhoneInput` (маска).
|
- **Контактный телефон** (`Phone`) — обязателен, `PhoneInput` (маска).
|
||||||
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
|
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
|
||||||
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
|
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
|
||||||
@@ -152,9 +166,10 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
|
|||||||
полей всего восемь, отметок подразделений нет.
|
полей всего восемь, отметок подразделений нет.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
RequestSection (сотрудник · должность · структурное подразделение · самостоятельное
|
RequestSection (сотрудник · организация/департамент/должность (таблица — форма
|
||||||
подразделение · организация (таблица у совместителей) · последний рабочий день ·
|
подачи; должность/департамент/список компаний — просмотр) · самостоятельное
|
||||||
телефон · комментарий · ссылка на обходной лист · лицо, принимающее ОС/НМА)
|
подразделение · последний рабочий день · телефон · комментарий · ссылка на
|
||||||
|
обходной лист · лицо, принимающее ОС/НМА)
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `hr-dismissal`
|
- rptype: `hr-dismissal`
|
||||||
|
|||||||
@@ -22,9 +22,16 @@ export interface IFormData {
|
|||||||
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
||||||
Employee: string;
|
Employee: string;
|
||||||
Position: string;
|
Position: string;
|
||||||
/** Структурное подразделение — из профиля (SPS-Department), read-only. */
|
/**
|
||||||
|
* Департамент — поле Section из Employee/ByEmail (справочник api.tnsu.kz),
|
||||||
|
* ПЕРВОЙ компании сотрудника (см. shared/dictionary/employeeOrganizations.
|
||||||
|
* service.ts). Read-only, как раньше со «Структурным подразделением» из
|
||||||
|
* профиля SharePoint — просто источник сменился на 1С/справочник портала.
|
||||||
|
* На форме подачи виден внутри OrganizationsTable (колонка «Департамент» по
|
||||||
|
* каждой компании), отдельного поля для него больше нет.
|
||||||
|
*/
|
||||||
Department: string;
|
Department: string;
|
||||||
/** Самостоятельное подразделение — в профиле его нет, вводится вручную. */
|
/** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */
|
||||||
StandaloneUnit: string;
|
StandaloneUnit: string;
|
||||||
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
|
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
|
||||||
LastWorkingDay: string;
|
LastWorkingDay: string;
|
||||||
@@ -86,3 +93,5 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
|
/** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */
|
||||||
|
export const MIN_NOTICE_MONTHS = 1;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
{ "internalName": "Department", "title": "Структурное подразделение", "type": "Text" },
|
{ "internalName": "Department", "title": "Департамент", "type": "Text" },
|
||||||
{ "internalName": "StandaloneUnit", "title": "Самостоятельное подразделение", "type": "Text" },
|
{ "internalName": "StandaloneUnit", "title": "Самостоятельное подразделение", "type": "Text" },
|
||||||
{ "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "Phone", "title": "Контактный телефон", "type": "Text" },
|
{ "internalName": "Phone", "title": "Контактный телефон", "type": "Text" },
|
||||||
|
|||||||
@@ -7,22 +7,46 @@
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px 13px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
.company,
|
.company,
|
||||||
|
.department,
|
||||||
.position {
|
.position {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 11px 13px;
|
padding: 11px 13px;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
|
border-right: 0;
|
||||||
background: $gray-50;
|
background: $gray-50;
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company {
|
.company {
|
||||||
border-right: 0;
|
width: 35%;
|
||||||
width: 55%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.position {
|
.department {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ячейка должности ПЕРВОЙ строки хранит TextInput (у него свой паддинг/рамка) —
|
||||||
|
// у самой ячейки паддинг убран, иначе поле ввода дублировало бы рамку карточки.
|
||||||
|
.positionInput {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormField, Icon } from '../../../shared/ui';
|
import { FormField, Icon, TextInput } from '../../../shared/ui';
|
||||||
import { IEmployeeOrganization } from '../../../shared/dictionary';
|
import { IEmployeeOrganization } from '../../../shared/dictionary';
|
||||||
import styles from './OrganizationsTable.module.scss';
|
import styles from './OrganizationsTable.module.scss';
|
||||||
|
|
||||||
interface IOrganizationsTableProps {
|
interface IOrganizationsTableProps {
|
||||||
records: IEmployeeOrganization[];
|
records: IEmployeeOrganization[];
|
||||||
|
/**
|
||||||
|
* Должность из ПЕРВОЙ (основной) компании — сюда же пишет форма (data.Position),
|
||||||
|
* единственная ячейка «Должность», которая реально редактируется. У остальных
|
||||||
|
* компаний (если сотрудник — совместитель) своя должность показывается как
|
||||||
|
* есть в справочнике, но не редактируется: маршрут согласования резолвится по
|
||||||
|
* ОДНОЙ должности основной компании (см. DismissalForm — fieldValues.Position),
|
||||||
|
* ставить в соответствие ей должности других компаний незачем.
|
||||||
|
*/
|
||||||
|
position: string;
|
||||||
|
onChangePosition: (value: string) => void;
|
||||||
/**
|
/**
|
||||||
* Убрать компанию из заявления — есть только на форме подачи (см.
|
* Убрать компанию из заявления — есть только на форме подачи (см.
|
||||||
* DismissalForm.handleRemoveOrganization), не в режиме просмотра: там состав
|
* DismissalForm.handleRemoveOrganization), не в режиме просмотра: там состав
|
||||||
@@ -14,28 +24,58 @@ interface IOrganizationsTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Организации совместителя — интерактивная таблица (не просто список строк, как
|
* Организации сотрудника — ЕДИНСТВЕННОЕ место на форме подачи, где видны и
|
||||||
* OrganizationField): у сотрудника, оформленного в НЕСКОЛЬКИХ компаниях
|
* правятся должность и подразделение (раньше были отдельными полями над этой
|
||||||
* одновременно, увольнение затрагивает директора/должностного эквивалента КАЖДОЙ
|
* таблицей — убраны как дублирование, см. историю формы). Интерактивная
|
||||||
* из них (маршрут `HeadType.CompanyDirectors` в approval-route резолвит их всех).
|
* таблица, а не список строк (как OrganizationField в режиме просмотра): у
|
||||||
* Если реально уходит только из одной — заявитель убирает лишние компании ЗДЕСЬ,
|
* сотрудника, оформленного в НЕСКОЛЬКИХ компаниях одновременно, увольнение
|
||||||
* и соответствующий согласующий убирается из матрицы автоматически (см.
|
* затрагивает директора/должностного эквивалента КАЖДОЙ из них (маршрут
|
||||||
* DismissalForm.handleRemoveOrganization) — не нужно отдельно искать его в матрице
|
* `HeadType.CompanyDirectors` в approval-route резолвит их всех). Если реально
|
||||||
* и удалять там вручную.
|
* уходит только из одной — заявитель убирает лишние компании ЗДЕСЬ, и
|
||||||
|
* соответствующий согласующий убирается из матрицы автоматически (см.
|
||||||
|
* DismissalForm.handleRemoveOrganization) — не нужно отдельно искать его в
|
||||||
|
* матрице и удалять там вручную.
|
||||||
|
*
|
||||||
|
* Пустой records (справочник api.tnsu.kz не нашёл сотрудника, см.
|
||||||
|
* employeeOrganizations.service) — не «ничего не показываем», а ОДНА строка с
|
||||||
|
* прочерками и редактируемой должностью: без этого заявление на человека без
|
||||||
|
* карточки в справочнике нельзя было бы подать вовсе (некуда вписать должность
|
||||||
|
* вручную — а маршрут согласования без неё не резолвит условие «IsManager»).
|
||||||
*/
|
*/
|
||||||
export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({ records, onRemove }) => {
|
export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({
|
||||||
if (records.length === 0) return null;
|
records,
|
||||||
|
position,
|
||||||
|
onChangePosition,
|
||||||
|
onRemove
|
||||||
|
}) => {
|
||||||
|
const rows = records.length > 0 ? records : [undefined];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField label="Организация" full>
|
<FormField label="Организация · подразделение · должность" required full>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.company}>Компания</th>
|
||||||
|
<th className={styles.department}>Департамент</th>
|
||||||
|
<th className={styles.position}>Должность</th>
|
||||||
|
{onRemove && <th className={styles.action} />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{records.map((r) => (
|
{rows.map((r, i) => (
|
||||||
<tr key={r.companyId || r.company}>
|
<tr key={r?.companyId || r?.company || 'manual'}>
|
||||||
<td className={styles.company}>{r.company}</td>
|
<td className={styles.company}>{r?.company || '—'}</td>
|
||||||
<td className={styles.position}>{r.position}</td>
|
<td className={styles.department}>{r?.section || '—'}</td>
|
||||||
|
{i === 0 ? (
|
||||||
|
<td className={styles.positionInput}>
|
||||||
|
<TextInput value={position} onChange={onChangePosition} placeholder="Введите должность" />
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<td className={styles.position}>{r?.position || '—'}</td>
|
||||||
|
)}
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<td className={styles.action}>
|
<td className={styles.action}>
|
||||||
|
{r && records.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.remove}
|
className={styles.remove}
|
||||||
@@ -45,6 +85,7 @@ export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({ records
|
|||||||
>
|
>
|
||||||
<Icon name="close" size="sm" />
|
<Icon name="close" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -10,22 +10,19 @@ import {
|
|||||||
LinkField
|
LinkField
|
||||||
} from '../../../shared/ui';
|
} from '../../../shared/ui';
|
||||||
import { IEmployeeOrganization } from '../../../shared/dictionary';
|
import { IEmployeeOrganization } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
|
||||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
import { isoDatePlusMonths } from '../../../shared/dateUtils';
|
||||||
import { OrganizationsTable } from './OrganizationsTable';
|
import { OrganizationsTable } from './OrganizationsTable';
|
||||||
|
|
||||||
// Структурное подразделение подтягивается из профиля при выборе сотрудника (см.
|
|
||||||
// DismissalForm.handleEmployeeChange — SharePoint User Profile), свободный ввод
|
|
||||||
// намеренно убран, чтобы не расходиться с профилем — как в «Обходном листе».
|
|
||||||
const NOOP = (): void => undefined;
|
|
||||||
|
|
||||||
interface IRequestSectionProps extends ISectionProps {
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
/**
|
/**
|
||||||
* Организации сотрудника СПИСКОМ (не joined-строкой из data.Organization) —
|
* Организации сотрудника СПИСКОМ (не joined-строкой из data.Organization) —
|
||||||
* нужны, только когда таблицу можно редактировать (форма подачи): без них
|
* заданы ТОЛЬКО на форме подачи (DismissalForm), где вместо read-only
|
||||||
* пришлось бы разбирать обратно уже отформатированный текст. Не задано —
|
* OrganizationField показывается интерактивная OrganizationsTable: она же
|
||||||
* секция показывает data.Organization как раньше, одной read-only колонкой
|
* теперь единственное место для должности и подразделения (см. её комментарий) —
|
||||||
* (OrganizationField) — так работает режим просмотра (DismissalView).
|
* отдельных полей выше больше нет, дублировать одно и то же дважды незачем.
|
||||||
|
* Не задано (DismissalView, режим просмотра) — секция показывает Должность,
|
||||||
|
* Подразделение и Организацию как раньше, простыми read-only полями.
|
||||||
*/
|
*/
|
||||||
orgRecords?: IEmployeeOrganization[];
|
orgRecords?: IEmployeeOrganization[];
|
||||||
/** Убрать компанию — см. OrganizationsTable. Только форма подачи. */
|
/** Убрать компанию — см. OrganizationsTable. Только форма подачи. */
|
||||||
@@ -57,47 +54,39 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{/* Подставляется по выбранному сотруднику, но остаётся редактируемым: если
|
{orgRecords ? (
|
||||||
справочник организаций не заполнен, должность нужно ввести вручную —
|
// Форма подачи: должность, департамент и компании — только здесь, ниже.
|
||||||
иначе шаг маршрута с условием по должности не соберётся. */}
|
// Стоит ПЕРЕД «Самостоятельным подразделением» специально: остальные поля
|
||||||
<FormField label="Должность" htmlFor="Position" required>
|
// формы читают уже подставленную должность/компанию, а не наоборот.
|
||||||
<TextInput
|
<OrganizationsTable
|
||||||
id="Position"
|
records={orgRecords}
|
||||||
value={data.Position}
|
position={data.Position}
|
||||||
onChange={(v) => set('Position', v)}
|
onChangePosition={(v) => set('Position', v)}
|
||||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
onRemove={onRemoveOrganization}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
|
||||||
<FormField label="Структурное подразделение" htmlFor="Department">
|
|
||||||
<TextInput
|
|
||||||
id="Department"
|
|
||||||
value={data.Department}
|
|
||||||
onChange={NOOP}
|
|
||||||
readOnly
|
|
||||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
|
|
||||||
<TextInput
|
|
||||||
id="StandaloneUnit"
|
|
||||||
value={data.StandaloneUnit}
|
|
||||||
onChange={(v) => set('StandaloneUnit', v)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{orgRecords && orgRecords.length > 1 ? (
|
|
||||||
<OrganizationsTable records={orgRecords} onRemove={onRemoveOrganization} />
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<FormField label="Должность" htmlFor="Position">
|
||||||
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Департамент" htmlFor="Department">
|
||||||
|
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
|
||||||
|
</FormField>
|
||||||
<OrganizationField value={data.Organization} />
|
<OrganizationField value={data.Organization} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Датой увольнения нельзя назначить прошедший день: заявление подаётся
|
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
|
||||||
заранее, а не задним числом (min = сегодня). */}
|
<TextInput id="StandaloneUnit" value={data.StandaloneUnit} onChange={(v) => set('StandaloneUnit', v)} />
|
||||||
|
</FormField>
|
||||||
|
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
|
||||||
|
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
|
||||||
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
|
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="LastWorkingDay"
|
id="LastWorkingDay"
|
||||||
type="date"
|
type="date"
|
||||||
value={data.LastWorkingDay}
|
value={data.LastWorkingDay}
|
||||||
onChange={(v) => set('LastWorkingDay', v)}
|
onChange={(v) => set('LastWorkingDay', v)}
|
||||||
min={isoDatePlusDays(0)}
|
min={isoDatePlusMonths(MIN_NOTICE_MONTHS)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Контактный телефон" htmlFor="Phone" required>
|
<FormField label="Контактный телефон" htmlFor="Phone" required>
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ export function isoDatePlusDays(days: number): string {
|
|||||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сегодня + n КАЛЕНДАРНЫХ месяцев в формате YYYY-MM-DD (для min/max у input
|
||||||
|
* type=date) — не приближение через 30 дней: `setMonth` сам разносит месяцы
|
||||||
|
* разной длины (28–31 день) и года на границе декабря/января.
|
||||||
|
*
|
||||||
|
* 31 января + 1 месяц → JS даёт 3 марта (переносит лишние дни на следующий
|
||||||
|
* месяц, а не откатывает до последнего числа) — для месячного уведомления это
|
||||||
|
* не проблема (крайняя ранняя дата и так сдвигается на несколько дней позже,
|
||||||
|
* не раньше), но не считать это «ровно через месяц» в общем случае.
|
||||||
|
*/
|
||||||
|
export function isoDatePlusMonths(months: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMonth(d.getMonth() + months);
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** YYYY-MM-DD → дд.мм.гггг, для текста ошибок валидации. Невалидную строку возвращает как есть. */
|
/** YYYY-MM-DD → дд.мм.гггг, для текста ошибок валидации. Невалидную строку возвращает как есть. */
|
||||||
export function formatIsoDate(iso: string): string {
|
export function formatIsoDate(iso: string): string {
|
||||||
const [y, m, d] = iso.split('-');
|
const [y, m, d] = iso.split('-');
|
||||||
|
|||||||
Reference in New Issue
Block a user