Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 1m9s

This commit is contained in:
Тимур Городецкий
2026-08-26 16:38:20 +05:00
18 changed files with 271 additions and 244 deletions
+1 -1
View File
@@ -45,7 +45,7 @@
| [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ | | [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ |
| [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ |
| [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 6 независимых пар «сумма к списанию + комментарий» (ОС, СИЗ, подотчётная сумма, обучение, НМА, ТМЦ) — вводит любой активный согласующий, блокируется навсегда после первого ввода | ✅ | | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | | [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
@@ -175,11 +175,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
[canEditWriteOffs, lockedWriteOffs] [canEditWriteOffs, lockedWriteOffs]
); );
// Категории, чью сумму заполняют ПРЯМО СЕЙЧАС (редактируема и не пуста) — от
// них зависит, обязателен ли комментарий решения (DecisionPanel, «Ваше
// решение») и что подставить в его плейсхолдер. «За что конкретно» здесь не
// хранится отдельным полем — вместо этого требуем стандартный комментарий
// решения, который и так сопровождает Согласовать/Вернуть/Отклонить (см.
// commentRequired/commentPlaceholder у DecisionPanel ниже).
const writeOffTouchedCategories = WRITE_OFF_CATEGORIES.filter(
(cat) => editableWriteOffKeys.has(cat.key) && data[cat.amountField].trim() !== ''
);
// Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он // Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он
// затёр бы чужие поля значениями, которые видел у себя на экране. Пары сумм к // затёр бы чужие поля значениями, которые видел у себя на экране. Суммы к
// списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys; частично // списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys.
// заполненная пара (сумма без комментария или наоборот) бросает ДО записи
// чего бы то ни было — половинчатая запись хуже отказа сохранить.
const saveApproverFields = useCallback(async (): Promise<void> => { const saveApproverFields = useCallback(async (): Promise<void> => {
const fields: Partial<IApproverEditableFields> = {}; const fields: Partial<IApproverEditableFields> = {};
if (hrRecordsEditable) { if (hrRecordsEditable) {
@@ -191,25 +199,15 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
} }
const writeOffFields: Partial<IWriteOffFields> = {}; const writeOffFields: Partial<IWriteOffFields> = {};
for (const cat of WRITE_OFF_CATEGORIES) { for (const cat of writeOffTouchedCategories) {
if (!editableWriteOffKeys.has(cat.key)) continue;
const amount = data[cat.amountField].trim();
const comment = data[cat.commentField].trim();
if (!amount && !comment) continue; // не трогали — не пишем
if (!amount || !comment) {
throw new Error(
`«${cat.label}»: заполните и сумму, и комментарий (за что конкретно) — или оставьте пару пустой.`
);
}
writeOffFields[cat.amountField] = data[cat.amountField]; writeOffFields[cat.amountField] = data[cat.amountField];
writeOffFields[cat.commentField] = data[cat.commentField];
} }
await updateApproverFields(itemId, fields, listId); await updateApproverFields(itemId, fields, listId);
if (Object.keys(writeOffFields).length > 0) { if (Object.keys(writeOffFields).length > 0) {
await updateWriteOffFields(itemId, writeOffFields, listId); await updateWriteOffFields(itemId, writeOffFields, listId);
} }
}, [itemId, listId, hrRecordsEditable, parkingEditable, editableWriteOffKeys, data]); }, [itemId, listId, hrRecordsEditable, parkingEditable, writeOffTouchedCategories, data]);
const handleSaveFields = async (): Promise<void> => { const handleSaveFields = async (): Promise<void> => {
setSaveState('saving'); setSaveState('saving');
@@ -324,6 +322,17 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
// Отметки сохраняются вместе с решением — иначе согласующий, забывший // Отметки сохраняются вместе с решением — иначе согласующий, забывший
// нажать «Сохранить», согласовал бы заявку со старыми значениями. // нажать «Сохранить», согласовал бы заявку со старыми значениями.
onBeforeDecide={canEditAnything ? saveApproverFields : undefined} onBeforeDecide={canEditAnything ? saveApproverFields : undefined}
// Заполнил сумму к списанию — комментарий решения обязателен (не
// только при возврате/отклонении, как обычно): это и есть «за что
// конкретно», отдельного поля под него нет (см. writeOffTouchedCategories).
commentRequired={writeOffTouchedCategories.length > 0}
commentPlaceholder={
writeOffTouchedCategories.length > 0
? `Введите, за что сумма${
writeOffTouchedCategories.length > 1 ? 'ы' : ''
}: ${writeOffTouchedCategories.map((c) => c.label).join(', ')}`
: undefined
}
onDecided={(newStatus) => { onDecided={(newStatus) => {
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
@@ -55,30 +55,57 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
заведённых руками, ссылки нет и поле не рендерится. заведённых руками, ссылки нет и поле не рендерится.
## Суммы к списанию (согласование) ## Суммы к списанию (согласование)
6 НЕЗАВИСИМЫХ пар «сумма + обязательный комментарий (за что конкретно)», колонки Отдельная `CollapseSection title="Суммы к списанию"` (в стиле бывшего общего
`WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}{Amount,Comment}` (см. `model.ts заголовка формы «Заявка», удалённого — см. `docs/forms.md`) объединяет
WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная сумма», «Обучение», «НМА», «ТМЦ». визуально ДВЕ РАЗНЫЕ по правам вещи:
1. **`TrainingAmount`** («Обучение») — доступность КАК И РАНЬШЕ: правит
только КДП (`hrRecordsEditable`, `approverRights.ts`), сумма НЕ блокируется
после ввода, никак не участвует в WRITE_OFF_CATEGORIES/`writeOffTouchedCategories`
(не требует комментария решения). Изменилась ТОЛЬКО колонка хранения — пишется
и читается через `WriteOffTrainingAmount` (`ClearanceView.saveApproverFields`,
`submit.ts.buildItem`, `load.ts`), а не через одноимённую `TrainingAmount` —
та осталась в схеме списка неиспользуемой. Секция видна независимо от
`writeOffEditableKeys` (само поле — без этого условия), поэтому на форме
подачи (`ClearanceForm`, `writeOffEditableKeys` не передан) поле видно и без
пяти сумм ниже.
2. **5 НЕЗАВИСИМЫХ сумм**, компактной сеткой (короткие подписи, обычная
2-колоночная сетка секции), колонки `WriteOff{Assets,Ppe,Accountable,Nma,
Tmc}Amount` (см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная
сумма», «НМА», «ТМЦ» — правит любой активный согласующий, см. ниже. Раньше
среди них было ещё и «Обучение» — убрали как дубль `TrainingAmount` с ДРУГИМ
поведением под тем же названием; общий заголовок группы остался.
- **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ - **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ
согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`, согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`,
фаза «Согласование» — исполнителям недоступно, как и остальным отметкам). фаза «Согласование» — исполнителям недоступно, как и остальным отметкам).
- **Каждая пара блокируется НАВСЕГДА после первого ввода** — как только сумма - **Каждая сумма блокируется НАВСЕГДА после первого ввода** — как только она
непустая, поле становится read-only для всех, включая того, кто её ввёл. непустая, поле становится read-only для всех, включая того, кто её ввёл.
Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы
(`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от (`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от
текущего `data` — иначе поле блокировалось бы прямо во время набора цифр). текущего `data` — иначе поле блокировалось бы прямо во время набора цифр).
- **Комментарий обязателен вместе с суммой**: заполнить можно либо обе половины - **«За что конкретно» — своего поля НЕТ.** Заполнил хоть одну сумму
пары, либо ни одной — `saveApproverFields` бросает ДО записи чего бы то ни (`ClearanceView.writeOffTouchedCategories`) — становится обязательным
было, если половина пары осталась пустой. СТАНДАРТНЫЙ комментарий решения: то самое поле «Комментарий» в блоке «Ваше
- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки, решение» (`DecisionPanel`), которым и так сопровождают Согласовать/Вернуть/
и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним. Отклонить — обычно обязательное только при возврате/отклонении, но не здесь
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к (`commentRequired` у `DecisionPanel`, см. её же новый проп). Плейсхолдер в этот
момент называет категории, которые нужно объяснить («Введите, за что сумма:
ОС, ТМЦ», см. `commentPlaceholder`). Комментарий уходит в СТРОКУ маршрута этого
согласующего (колонка `Comment` списка согласования) — как обычно, отдельного
поля под него на данных заявки не заводили.
- Суммы сохраняются кнопкой «Сохранить» (`reviewBar`) и тем же `onBeforeDecide`
— при решении несохранённые суммы уйдут вместе с ним, ДО проверки
обязательности комментария (см. `DecisionPanel.decide`).
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение» выше, привязана к
КДП) — сознательно не объединялись, чтобы не менять уже установившееся КДП) — сознательно не объединялись, чтобы не менять уже установившееся
поведение существующего поля. поведение существующего поля.
## Секции формы ## Секции формы
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма RequestSection (сотрудник · должность · подразделение · дата расторжения ·
обучения · парковочная карта · карта СКУД · суммы к списанию · ссылка на заявление) парковочная карта · карта СКУД — без заголовка, `FormGrid`) + отдельная
CollapseSection «Суммы к списанию» (обучение + 5 сумм списания) + ссылка на
заявление
## Точки входа ## Точки входа
- rptype: `clearance-form` - rptype: `clearance-form`
@@ -35,23 +35,16 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'TerminationDate', 'TerminationDate',
'ParkingCard', 'ParkingCard',
'AccessCard', 'AccessCard',
'TrainingAmount',
'Organization', 'Organization',
'Project', 'Project',
'ProjectId', 'ProjectId',
'DismissalUrl', 'DismissalUrl',
'WriteOffAssetsAmount', 'WriteOffAssetsAmount',
'WriteOffAssetsComment',
'WriteOffPpeAmount', 'WriteOffPpeAmount',
'WriteOffPpeComment',
'WriteOffAccountableAmount', 'WriteOffAccountableAmount',
'WriteOffAccountableComment',
'WriteOffTrainingAmount', 'WriteOffTrainingAmount',
'WriteOffTrainingComment',
'WriteOffNmaAmount', 'WriteOffNmaAmount',
'WriteOffNmaComment',
'WriteOffTmcAmount', 'WriteOffTmcAmount',
'WriteOffTmcComment',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/Title', 'Employee/Title',
@@ -61,7 +54,6 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
const employee = item.Employee as { Title?: string; EMail?: string } | undefined; const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const termination = (item.TerminationDate as string) || ''; const termination = (item.TerminationDate as string) || '';
const amount = item.TrainingAmount;
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
@@ -71,24 +63,19 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
ParkingCard: !!item.ParkingCard, ParkingCard: !!item.ParkingCard,
AccessCard: !!item.AccessCard, AccessCard: !!item.AccessCard,
TrainingAmount: amount === null || amount === undefined ? '' : String(amount), // Хранится в WriteOffTrainingAmount, не в одноимённом TrainingAmount — см.
// IFormData.TrainingAmount.
TrainingAmount: numberField(item.WriteOffTrainingAmount),
Organization: (item.Organization as string) || '', Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '', Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '', ProjectId: (item.ProjectId as string) || '',
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес. // Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '', DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '',
WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount), WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount),
WriteOffAssetsComment: (item.WriteOffAssetsComment as string) || '',
WriteOffPpeAmount: numberField(item.WriteOffPpeAmount), WriteOffPpeAmount: numberField(item.WriteOffPpeAmount),
WriteOffPpeComment: (item.WriteOffPpeComment as string) || '',
WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount), WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount),
WriteOffAccountableComment: (item.WriteOffAccountableComment as string) || '',
WriteOffTrainingAmount: numberField(item.WriteOffTrainingAmount),
WriteOffTrainingComment: (item.WriteOffTrainingComment as string) || '',
WriteOffNmaAmount: numberField(item.WriteOffNmaAmount), WriteOffNmaAmount: numberField(item.WriteOffNmaAmount),
WriteOffNmaComment: (item.WriteOffNmaComment as string) || '', WriteOffTmcAmount: numberField(item.WriteOffTmcAmount)
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount),
WriteOffTmcComment: (item.WriteOffTmcComment as string) || ''
}; };
return { return {
@@ -23,7 +23,17 @@ export interface IFormData {
TerminationDate: string; TerminationDate: string;
ParkingCard: boolean; ParkingCard: boolean;
AccessCard: boolean; AccessCard: boolean;
/** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */ /**
* Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* указана. Доступность — КАК И РАНЬШЕ: правит только начальник КДП
* (`hrRecordsEditable`, `approverRights.ts`), не блокируется после ввода (в
* отличие от WRITE_OFF_CATEGORIES ниже). Единственное, что изменилось, —
* КОЛОНКА ХРАНЕНИЯ: пишется и читается через `WriteOffTrainingAmount` (см.
* ClearanceView.saveApproverFields/load.ts), а не через одноимённый
* `TrainingAmount` в списке — тот остался в схеме неиспользуемым (как и
* другие столбцы, из которых код этой сессии перестал писать/читать, см.
* README «Убрано поле…»).
*/
TrainingAmount: string; TrainingAmount: string;
/** /**
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) — * Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
@@ -43,74 +53,62 @@ export interface IFormData {
DismissalUrl: string; DismissalUrl: string;
/** /**
* Суммы к списанию по обходному листу — 6 независимых пар (сумма + * Суммы к списанию по обходному листу — 5 независимых сумм (компактная сетка
* обязательный комментарий, «за что конкретно»), см. WRITE_OFF_CATEGORIES. * полей, см. WRITE_OFF_CATEGORIES). Обучение сюда НЕ входит — у него другая
* «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто = * доступность (см. TrainingAmount выше): эти пять доступны ЛЮБОМУ активному
* не заполнено. КАЖДАЯ пара — независимая: заполнить может любой активный * согласующему и блокируются НАВСЕГДА после первого ввода (включая того, кто
* согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма * ввёл) — признак блокировки: сама непустая сумма, отдельного флага в
* непустая — пара блокируется НАВСЕГДА, для всех (включая того, кто её ввёл). * списке нет. «Сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* Признак блокировки — сама непустая сумма, отдельного флага в списке нет. * заполнено.
*
* «За что конкретно» — своего поля НЕТ: заполнил хоть одну сумму — становится
* обязательным СТАНДАРТНЫЙ комментарий решения (DecisionPanel, «Ваше решение»),
* тот же, каким и так сопровождают Согласовать/Вернуть/Отклонить. Он уже
* хранится построчно в списке согласования (колонка Comment на шаге этого
* согласующего) — заводить для него отдельное поле на данных заявки незачем
* (см. ClearanceView — commentRequired/commentPlaceholder у DecisionPanel).
*/ */
WriteOffAssetsAmount: string; WriteOffAssetsAmount: string;
WriteOffAssetsComment: string;
WriteOffPpeAmount: string; WriteOffPpeAmount: string;
WriteOffPpeComment: string;
WriteOffAccountableAmount: string; WriteOffAccountableAmount: string;
WriteOffAccountableComment: string;
WriteOffTrainingAmount: string;
WriteOffTrainingComment: string;
WriteOffNmaAmount: string; WriteOffNmaAmount: string;
WriteOffNmaComment: string;
WriteOffTmcAmount: string; WriteOffTmcAmount: string;
WriteOffTmcComment: string; /**
* Колонка ХРАНЕНИЯ для TrainingAmount (см. выше) — НЕ участвует в
* WRITE_OFF_CATEGORIES/WriteOffFieldKey и в общем механизме «любой
* согласующий, блокировка навсегда»: доступность и незаблокируемость у
* «Обучение: сумма» остались как были, изменилась только SharePoint-колонка.
*/
WriteOffTrainingAmount: string;
} }
/** Имена всех 12 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */ /** Имена 5 сумм к списанию (не считая WriteOffTrainingAmount — у него своя доступность) — см. IFormData.WriteOff*Amount. */
export type WriteOffFieldKey = export type WriteOffFieldKey =
| 'WriteOffAssetsAmount' | 'WriteOffAssetsAmount'
| 'WriteOffAssetsComment'
| 'WriteOffPpeAmount' | 'WriteOffPpeAmount'
| 'WriteOffPpeComment'
| 'WriteOffAccountableAmount' | 'WriteOffAccountableAmount'
| 'WriteOffAccountableComment'
| 'WriteOffTrainingAmount'
| 'WriteOffTrainingComment'
| 'WriteOffNmaAmount' | 'WriteOffNmaAmount'
| 'WriteOffNmaComment' | 'WriteOffTmcAmount';
| 'WriteOffTmcAmount'
| 'WriteOffTmcComment';
/** /**
* Одна из 6 независимых категорий списания — см. IFormData.WriteOff*. * Одна из 5 независимых категорий списания — см. IFormData.WriteOff*Amount.
* amountField/commentField сужены до WriteOffFieldKey (а не keyof IFormData * amountField сужен до WriteOffFieldKey (а не keyof IFormData целиком): у
* целиком): у IFormData есть и булевы поля (ParkingCard/AccessCard), и * IFormData есть и булевы поля (ParkingCard/AccessCard), и `data[cat.amountField]`
* `data[cat.amountField]` должен типизироваться строго строкой — иначе * должен типизироваться строго строкой — иначе `.trim()` в валидации и
* `.trim()` в валидации и передача в MoneyInput/TextArea не собираются. * передача в MoneyInput не собираются.
*/ */
export interface IWriteOffCategory { export interface IWriteOffCategory {
key: string; key: string;
label: string; label: string;
amountField: WriteOffFieldKey; amountField: WriteOffFieldKey;
commentField: WriteOffFieldKey;
} }
export const WRITE_OFF_CATEGORIES: IWriteOffCategory[] = [ export const WRITE_OFF_CATEGORIES: IWriteOffCategory[] = [
{ key: 'assets', label: 'ОС', amountField: 'WriteOffAssetsAmount', commentField: 'WriteOffAssetsComment' }, { key: 'assets', label: 'ОС', amountField: 'WriteOffAssetsAmount' },
{ key: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount', commentField: 'WriteOffPpeComment' }, { key: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount' },
{ { key: 'accountable', label: 'Подотчётная сумма', amountField: 'WriteOffAccountableAmount' },
key: 'accountable', { key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount' },
label: 'Подотчётная сумма', { key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount' }
amountField: 'WriteOffAccountableAmount',
commentField: 'WriteOffAccountableComment'
},
{
key: 'training',
label: 'Обучение',
amountField: 'WriteOffTrainingAmount',
commentField: 'WriteOffTrainingComment'
},
{ key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount', commentField: 'WriteOffNmaComment' },
{ key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount', commentField: 'WriteOffTmcComment' }
]; ];
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -143,17 +141,11 @@ export const EMPTY_DATA: IFormData = {
ProjectId: '', ProjectId: '',
DismissalUrl: '', DismissalUrl: '',
WriteOffAssetsAmount: '', WriteOffAssetsAmount: '',
WriteOffAssetsComment: '',
WriteOffPpeAmount: '', WriteOffPpeAmount: '',
WriteOffPpeComment: '',
WriteOffAccountableAmount: '', WriteOffAccountableAmount: '',
WriteOffAccountableComment: '',
WriteOffTrainingAmount: '', WriteOffTrainingAmount: '',
WriteOffTrainingComment: '',
WriteOffNmaAmount: '', WriteOffNmaAmount: '',
WriteOffNmaComment: '', WriteOffTmcAmount: ''
WriteOffTmcAmount: '',
WriteOffTmcComment: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -19,16 +19,10 @@
{ "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" }, { "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" },
{ "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAssetsComment", "title": "Комментарий: ОС", "type": "Note" },
{ "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffPpeComment", "title": "Комментарий: СИЗ", "type": "Note" },
{ "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAccountableComment", "title": "Комментарий: подотчётная сумма", "type": "Note" },
{ "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTrainingComment", "title": "Комментарий: обучение", "type": "Note" },
{ "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffNmaComment", "title": "Комментарий: НМА", "type": "Note" }, { "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 }
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTmcComment", "title": "Комментарий: ТМЦ", "type": "Note" }
] ]
} }
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextArea, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui'; import { FormGrid, CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model';
@@ -33,39 +33,41 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
parkingEditable, parkingEditable,
printOrgIndex, printOrgIndex,
writeOffEditableKeys writeOffEditableKeys
}) => ( }) => {
<CollapseSection title="Обходной лист" icon="people"> return (
<FormField label="Сотрудник" htmlFor="Employee" required full> <>
<PeopleField <FormGrid>
context={context} <FormField label="Сотрудник" htmlFor="Employee" required full>
value={data.Employee} <PeopleField
onChange={onEmployeeChange ?? ((v) => set('Employee', v))} context={context}
placeholder={EMPLOYEE_PLACEHOLDER} value={data.Employee}
/> onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
</FormField> placeholder={EMPLOYEE_PLACEHOLDER}
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся />
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст), </FormField>
HR должен суметь ввести должность вручную — иначе маршрут {/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
согласования (условие «IsManager» резолвится именно по ней) не редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
соберётся, и список исполнителей ниже останется пустым. */} HR должен суметь ввести должность вручную — иначе маршрут
<FormField label="Должность" htmlFor="Position" required> согласования (условие «IsManager» резолвится именно по ней) не
<TextInput соберётся, и список исполнителей ниже останется пустым. */}
id="Position" <FormField label="Должность" htmlFor="Position" required>
value={data.Position} <TextInput
onChange={(v) => set('Position', v)} id="Position"
placeholder="Подставится автоматически по выбранному сотруднику" value={data.Position}
/> onChange={(v) => set('Position', v)}
</FormField> placeholder="Подставится автоматически по выбранному сотруднику"
<FormField label="Подразделение" htmlFor="Department"> />
<TextInput </FormField>
id="Department" <FormField label="Подразделение" htmlFor="Department">
value={data.Department} <TextInput
onChange={NOOP} id="Department"
readOnly value={data.Department}
placeholder="Подставится автоматически по выбранному сотруднику" onChange={NOOP}
/> readOnly
</FormField> placeholder="Подставится автоматически по выбранному сотруднику"
<OrganizationField value={data.Organization} printIndex={printOrgIndex} /> />
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Проект" htmlFor="Project"> <FormField label="Проект" htmlFor="Project">
<ProjectPicker <ProjectPicker
id="Project" id="Project"
@@ -78,52 +80,65 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder="Начните вводить название или код проекта…" placeholder="Начните вводить название или код проекта…"
/> />
</FormField> </FormField>
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required> <FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
<TextInput <TextInput
id="TerminationDate" id="TerminationDate"
type="date" type="date"
value={data.TerminationDate} value={data.TerminationDate}
onChange={(v) => set('TerminationDate', v)} onChange={(v) => set('TerminationDate', v)}
/> />
</FormField> </FormField>
{/* Явный readOnly={false} перебивает read-only контекст просмотра (профильный <FormField label="Парковочная карта">
согласующий правит своё поле); undefined — обычное поведение из FormMode. */} <Toggle
<FormField label="Обучение: сумма" htmlFor="TrainingAmount"> checked={data.ParkingCard}
<MoneyInput onChange={(v) => set('ParkingCard', v)}
id="TrainingAmount" onLabel="Да"
value={data.TrainingAmount} readOnly={parkingEditable ? false : undefined}
onChange={(v) => set('TrainingAmount', v)} />
readOnly={hrRecordsEditable ? false : undefined} </FormField>
/> <FormField label="Карта СКУД">
</FormField> <Toggle
<FormField label="Парковочная карта"> checked={data.AccessCard}
<Toggle onChange={(v) => set('AccessCard', v)}
checked={data.ParkingCard} onLabel="Да"
onChange={(v) => set('ParkingCard', v)} readOnly={hrRecordsEditable ? false : undefined}
onLabel="Да" />
readOnly={parkingEditable ? false : undefined} </FormField>
/> </FormGrid>
</FormField> {/* Суммы к списанию — своя CollapseSection (тот же стиль заголовка, что
<FormField label="Карта СКУД"> раньше был у общего «Заявка»), объединяет визуально ДВЕ РАЗНЫЕ по
<Toggle правам вещи:
checked={data.AccessCard} - TrainingAmount («Обучение») — как раньше: правит только КДП
onChange={(v) => set('AccessCard', v)} (hrRecordsEditable), без блокировки после ввода; хранится в
onLabel="Да" WriteOffTrainingAmount (не одноимённой колонке, см.
readOnly={hrRecordsEditable ? false : undefined} IFormData.TrainingAmount), но это деталь хранения, на форму не
/> влияет. Показывается всегда, даже без writeOffEditableKeys (форма
</FormField> подачи).
{/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий, - WRITE_OFF_CATEGORIES (5 сумм) — заполнить может ЛЮБОЙ активный
за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий согласующий (не только профильный), но как только сумма указана,
(не только профильный, в отличие от отметок выше) — но как только сумма блокируется НАВСЕГДА для всех, поэтому у незаблокированных
указана, пара блокируется навсегда для всех, поэтому у незаблокированных (writeOffEditableKeys.has(cat.key)) readOnly={false}, у остальных
(writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных — — обычное поведение FormMode. «За что конкретно» у них — своего
обычное поведение FormMode (просмотр — заблокировано и так). */} поля нет: заполнил хоть одну сумму — обязательным становится
{writeOffEditableKeys && СТАНДАРТНЫЙ комментарий решения в DecisionPanel («Ваше решение»),
WRITE_OFF_CATEGORIES.map((cat) => { см. ClearanceView (commentRequired/commentPlaceholder). У
const editable = writeOffEditableKeys.has(cat.key); TrainingAmount этого правила нет — комментарий не требует. Сама
return ( секция видна всегда (Обучение доступно и на форме подачи), а 5
<React.Fragment key={cat.key}> категорий — только на согласовании (writeOffEditableKeys задан). */}
<FormField label={`${cat.label}: сумма`} htmlFor={cat.amountField}> <CollapseSection title="Суммы к списанию" icon="calculator">
<FormField label="Обучение" htmlFor="TrainingAmount">
<MoneyInput
id="TrainingAmount"
value={data.TrainingAmount}
onChange={(v) => set('TrainingAmount', v)}
readOnly={hrRecordsEditable ? false : undefined}
/>
</FormField>
{writeOffEditableKeys &&
WRITE_OFF_CATEGORIES.map((cat) => {
const editable = writeOffEditableKeys.has(cat.key);
return (
<FormField key={cat.key} label={cat.label} htmlFor={cat.amountField}>
<MoneyInput <MoneyInput
id={cat.amountField} id={cat.amountField}
value={data[cat.amountField]} value={data[cat.amountField]}
@@ -131,22 +146,9 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={editable ? false : undefined} readOnly={editable ? false : undefined}
/> />
</FormField> </FormField>
<FormField );
label={`${cat.label}: за что конкретно`} })}
htmlFor={cat.commentField} </CollapseSection>
required={editable}
full
>
<TextArea
id={cat.commentField}
value={data[cat.commentField]}
onChange={(v) => set(cat.commentField, v)}
readOnly={editable ? false : undefined}
/>
</FormField>
</React.Fragment>
);
})}
{/* Есть только у листов, созданных автоматически по согласованному заявлению {/* Есть только у листов, созданных автоматически по согласованному заявлению
на увольнение (см. forms/Dismissal/createClearance.ts). */} на увольнение (см. forms/Dismissal/createClearance.ts). */}
<LinkField <LinkField
@@ -154,5 +156,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
url={data.DismissalUrl} url={data.DismissalUrl}
text="Открыть заявление, по которому создан этот лист" text="Открыть заявление, по которому создан этот лист"
/> />
</CollapseSection> </>
); );
};
@@ -3,18 +3,17 @@ import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/
import { ensureEmployee } from '../../shared/employee'; import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData, WriteOffFieldKey } from './model'; import { FORM_ID, IFormData, WriteOffFieldKey } from './model';
/** Одна из 12 колонок сумм/комментариев к списанию — см. model.ts WRITE_OFF_CATEGORIES. */ /** 5 сумм к списанию — см. model.ts WRITE_OFF_CATEGORIES. «За что конкретно» — не здесь, см. IFormData. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>; export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>;
/** /**
* Записать сумму(ы)/комментарий(и) к списанию. Пишет РОВНО переданные поля — * Записать сумму(ы) к списанию. Пишет РОВНО переданные поля — как
* как updateApproverFields, каждая пара независима. * updateApproverFields, каждая независимо.
* *
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО те * ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО ещё
* пары, что ещё не заблокированы (см. IFormData.WriteOff* — блокировка по * не заблокированные суммы (см. IFormData.WriteOff* — блокировка по непустой
* непустой сумме) и заполнены целиком (сумма+комментарий вместе, см. * сумме) — эта функция сама повторно ничего не проверяет и молча перезапишет
* validateWriteOffPairs) — эта функция сама повторно ничего не проверяет и * то, что ей передали.
* молча перезапишет то, что ей передали.
*/ */
export async function updateWriteOffFields( export async function updateWriteOffFields(
id: number, id: number,
@@ -57,6 +56,11 @@ export interface IApproverEditableFields {
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние * Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
* ключи затёрли бы чужие отметки значениями из формы этого согласующего. * ключи затёрли бы чужие отметки значениями из формы этого согласующего.
* *
* fields.TrainingAmount пишется в колонку WriteOffTrainingAmount, а НЕ в
* одноимённый TrainingAmount — см. IFormData.TrainingAmount; доступность
* (только КДП, без блокировки навсегда) не изменилась, изменилась только
* колонка хранения.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан — * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан —
* текущая партиция, как раньше. * текущая партиция, как раньше.
*/ */
@@ -70,7 +74,7 @@ export async function updateApproverFields(
if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard; if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard;
if (fields.TrainingAmount !== undefined) { if (fields.TrainingAmount !== undefined) {
const amount = Number(fields.TrainingAmount); const amount = Number(fields.TrainingAmount);
item.TrainingAmount = item.WriteOffTrainingAmount =
Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null; Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null;
} }
if (Object.keys(item).length === 0) return; if (Object.keys(item).length === 0) return;
@@ -88,7 +92,13 @@ function buildTitle(employeeName: string): string {
return `Обходной лист — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`; return `Обходной лист — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
} }
/** Поля элемента списка данных из модели формы (+ Employee по Id). */ /**
* Поля элемента списка данных из модели формы (+ Employee по Id).
*
* Обычно data.TrainingAmount тут пуст (заявитель его не заполняет, только КДП
* на своём шаге — см. updateApproverFields), но пишем на случай редактирования
* уже созданной заявки: колонка WriteOffTrainingAmount, та же, что и там.
*/
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> { function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
const amount = Number(data.TrainingAmount); const amount = Number(data.TrainingAmount);
return { return {
@@ -100,7 +110,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TerminationDate: data.TerminationDate || null, TerminationDate: data.TerminationDate || null,
ParkingCard: data.ParkingCard, ParkingCard: data.ParkingCard,
AccessCard: data.AccessCard, AccessCard: data.AccessCard,
TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null, WriteOffTrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null,
Organization: data.Organization || null, Organization: data.Organization || null,
Project: data.Project || null, Project: data.Project || null,
ProjectId: data.ProjectId || null ProjectId: data.ProjectId || null
@@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
отдельных полей для них нет (были бы дублированием). `data.Organization: отдельных полей для них нет (были бы дублированием). `data.Organization:
IOrganizationRecord[]` — массив `{company, department, position}`; поле называется IOrganizationRecord[]` — массив `{company, department, position}`; поле называется
`department` (тот же справочник `Section`, что и раньше — см. следующий абзац), `department` (тот же справочник `Section`, что и раньше — см. следующий абзац),
колонка называется «Структурное подразделение», и то и другое read-only. Должность колонка называется «Подразделение», и то и другое read-only. Должность
редактируема В ЛЮБОЙ строке (справочник не всегда точен), но маршрут (условие редактируема В ЛЮБОЙ строке (справочник не всегда точен), но маршрут (условие
«IsManager») и обязательность по валидации завязаны ТОЛЬКО на должность ПЕРВОЙ «IsManager») и обязательность по валидации завязаны ТОЛЬКО на должность ПЕРВОЙ
(основной) записи — `primaryPosition(data.Organization)`; должности остальных (основной) записи — `primaryPosition(data.Organization)`; должности остальных
@@ -52,8 +52,6 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
/ `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка, / `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка,
сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна
компания (см. `parseOrganizationColumn`). компания (см. `parseOrganizationColumn`).
- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет,
свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы.
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через - **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через
`MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`, `MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`,
`min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок `min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок
@@ -173,9 +171,8 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
## Секции формы ## Секции формы
RequestSection (сотрудник · организация/департамент/должность (таблица — RequestSection (сотрудник · организация/департамент/должность (таблица —
редактируемая на форме подачи, read-only в просмотре) · самостоятельное редактируемая на форме подачи, read-only в просмотре) · последний рабочий день ·
подразделение · последний рабочий день · телефон · комментарий · ссылка на телефон · комментарий · ссылка на обходной лист · лицо, принимающее ОС/НМА)
обходной лист · лицо, принимающее ОС/НМА)
## Точки входа ## Точки входа
- rptype: `hr-dismissal` - rptype: `hr-dismissal`
@@ -27,7 +27,6 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
'Id', 'Id',
'Position', 'Position',
'Department', 'Department',
'StandaloneUnit',
'LastWorkingDay', 'LastWorkingDay',
'Phone', 'Phone',
'Comment', 'Comment',
@@ -48,7 +47,6 @@ 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 || '',
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) || '',
@@ -86,8 +86,6 @@ export function zipOrganization(
export interface IFormData { export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
Employee: string; Employee: string;
/** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */
StandaloneUnit: string;
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */ /** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
LastWorkingDay: string; LastWorkingDay: string;
/** Контактный телефон (маска, см. PhoneInput). */ /** Контактный телефон (маска, см. PhoneInput). */
@@ -139,7 +137,6 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Employee: '', Employee: '',
StandaloneUnit: '',
LastWorkingDay: '', LastWorkingDay: '',
Phone: '', Phone: '',
Comment: '', Comment: '',
@@ -7,7 +7,6 @@
{ "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": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true }, { "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true },
{ "internalName": "Phone", "title": "Контактный телефон", "type": "Text" }, { "internalName": "Phone", "title": "Контактный телефон", "type": "Text" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" },
@@ -56,7 +56,7 @@ export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({ records
<thead> <thead>
<tr> <tr>
<th className={styles.company}>Компания</th> <th className={styles.company}>Компания</th>
<th className={styles.department}>Структурное подразделение</th> <th className={styles.department}>Подразделение</th>
<th className={styles.position}>Должность</th> <th className={styles.position}>Должность</th>
{onRemove && <th className={styles.action} />} {onRemove && <th className={styles.action} />}
</tr> </tr>
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
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';
@@ -28,7 +28,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onRemoveOrganization, onRemoveOrganization,
assetsAcceptorEditable assetsAcceptorEditable
}) => ( }) => (
<CollapseSection title="Заявление на увольнение" icon="document"> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
context={context} context={context}
@@ -55,9 +55,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
} }
onRemove={onRemoveOrganization} onRemove={onRemoveOrganization}
/> />
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
<TextInput id="StandaloneUnit" value={data.StandaloneUnit} onChange={(v) => set('StandaloneUnit', v)} />
</FormField>
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе, {/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */} чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required> <FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
@@ -94,5 +91,5 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={assetsAcceptorEditable ? false : true} readOnly={assetsAcceptorEditable ? false : true}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -85,7 +85,6 @@ function buildItem(
Position: organizationColumn(data.Organization, (r) => r.position, 'Должность', TEXT_COLUMN_LIMIT), Position: organizationColumn(data.Organization, (r) => r.position, 'Должность', TEXT_COLUMN_LIMIT),
Department: organizationColumn(data.Organization, (r) => r.department, 'Департамент', TEXT_COLUMN_LIMIT), Department: organizationColumn(data.Organization, (r) => r.department, 'Департамент', TEXT_COLUMN_LIMIT),
Organization: organizationColumn(data.Organization, (r) => r.company, 'Организация'), Organization: organizationColumn(data.Organization, (r) => r.company, 'Организация'),
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
@@ -1,12 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */ /** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на отпуск без содержания" icon="people"> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
context={context} context={context}
@@ -65,5 +65,5 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER} placeholder={SUBSTITUTE_PLACEHOLDER}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -1,12 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */ /** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на трудовой отпуск" icon="people"> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
context={context} context={context}
@@ -62,5 +62,5 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER} placeholder={SUBSTITUTE_PLACEHOLDER}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -33,6 +33,17 @@ interface IDecisionPanelProps {
search?: (query: string) => Promise<ISiteUser[]>; search?: (query: string) => Promise<ISiteUser[]>;
/** Вызывается после успешного переназначения (обновить экран/матрицу). */ /** Вызывается после успешного переназначения (обновить экран/матрицу). */
onDelegated?: () => void; onDelegated?: () => void;
/**
* Дополнительное условие обязательности комментария — СВЕРХ обычного
* «возврат/отклонение». Форме есть что объяснять не только при отказе:
* например, «Обходной лист» требует его на ЛЮБОЕ решение, если согласующий
* только что заполнил сумму к списанию — своего поля «за что конкретно» у
* формы нет, вместо него используется этот самый комментарий (см.
* ClearanceView.writeOffTouchedCategories).
*/
commentRequired?: boolean;
/** Переопределить плейсхолдер поля комментария (по умолчанию — про возврат/отклонение). */
commentPlaceholder?: string;
} }
// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или // Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или
@@ -45,6 +56,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
onBeforeDecide, onBeforeDecide,
search, search,
onDelegated, onDelegated,
commentRequired,
commentPlaceholder,
}) => { }) => {
const [checking, setChecking] = useState<boolean>(true); const [checking, setChecking] = useState<boolean>(true);
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined); const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
@@ -84,10 +97,15 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
if (!pendingRow) { if (!pendingRow) {
return; return;
} }
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) { const commentEmpty = comment.trim().length === 0;
if (commentEmpty && (decision === 'return' || decision === 'reject')) {
setError('Для возврата или отклонения укажите комментарий.'); setError('Для возврата или отклонения укажите комментарий.');
return; return;
} }
if (commentEmpty && commentRequired) {
setError('Укажите комментарий.');
return;
}
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
try { try {
@@ -300,12 +318,12 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}> <div className={styles.panel}>
<div className={styles.title}>Ваше решение</div> <div className={styles.title}>Ваше решение</div>
{error && <Alert variant="err">{error}</Alert>} {error && <Alert variant="err">{error}</Alert>}
<FormField label="Комментарий" htmlFor="decisionComment"> <FormField label="Комментарий" htmlFor="decisionComment" required={commentRequired}>
<TextArea <TextArea
id="decisionComment" id="decisionComment"
value={comment} value={comment}
onChange={setComment} onChange={setComment}
placeholder="Комментарий (обязателен при возврате или отклонении)" placeholder={commentPlaceholder || 'Комментарий (обязателен при возврате или отклонении)'}
/> />
</FormField> </FormField>
<div className={styles.actions}> <div className={styles.actions}>