Правки (#16)
Tests / frontend-test (push) Successful in 1m11s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/16
This commit was merged in pull request #16.
This commit is contained in:
2026-08-26 11:10:26 +00:00
parent 418e30dfc3
commit 92b95050fa
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); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ |
| [`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 (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
| [`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-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
@@ -172,11 +172,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
[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 fields: Partial<IApproverEditableFields> = {};
if (hrRecordsEditable) {
@@ -188,25 +196,15 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
}
const writeOffFields: Partial<IWriteOffFields> = {};
for (const cat of WRITE_OFF_CATEGORIES) {
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}»: заполните и сумму, и комментарий (за что конкретно) — или оставьте пару пустой.`
);
}
for (const cat of writeOffTouchedCategories) {
writeOffFields[cat.amountField] = data[cat.amountField];
writeOffFields[cat.commentField] = data[cat.commentField];
}
await updateApproverFields(itemId, fields, listId);
if (Object.keys(writeOffFields).length > 0) {
await updateWriteOffFields(itemId, writeOffFields, listId);
}
}, [itemId, listId, hrRecordsEditable, parkingEditable, editableWriteOffKeys, data]);
}, [itemId, listId, hrRecordsEditable, parkingEditable, writeOffTouchedCategories, data]);
const handleSaveFields = async (): Promise<void> => {
setSaveState('saving');
@@ -321,6 +319,17 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
// Отметки сохраняются вместе с решением — иначе согласующий, забывший
// нажать «Сохранить», согласовал бы заявку со старыми значениями.
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) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -55,30 +55,57 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
заведённых руками, ссылки нет и поле не рендерится.
## Суммы к списанию (согласование)
6 НЕЗАВИСИМЫХ пар «сумма + обязательный комментарий (за что конкретно)», колонки
`WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}{Amount,Comment}` (см. `model.ts
WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная сумма», «Обучение», «НМА», «ТМЦ».
Отдельная `CollapseSection title="Суммы к списанию"` (в стиле бывшего общего
заголовка формы «Заявка», удалённого — см. `docs/forms.md`) объединяет
визуально ДВЕ РАЗНЫЕ по правам вещи:
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`,
фаза «Согласование» — исполнителям недоступно, как и остальным отметкам).
- **Каждая пара блокируется НАВСЕГДА после первого ввода** — как только сумма
- **Каждая сумма блокируется НАВСЕГДА после первого ввода** — как только она
непустая, поле становится read-only для всех, включая того, кто её ввёл.
Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы
(`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от
текущего `data` — иначе поле блокировалось бы прямо во время набора цифр).
- **Комментарий обязателен вместе с суммой**: заполнить можно либо обе половины
пары, либо ни одной — `saveApproverFields` бросает ДО записи чего бы то ни
было, если половина пары осталась пустой.
- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки,
и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним.
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к
- **«За что конкретно» — своего поля НЕТ.** Заполнил хоть одну сумму
(`ClearanceView.writeOffTouchedCategories`) — становится обязательным
СТАНДАРТНЫЙ комментарий решения: то самое поле «Комментарий» в блоке «Ваше
решение» (`DecisionPanel`), которым и так сопровождают Согласовать/Вернуть/
Отклонить — обычно обязательное только при возврате/отклонении, но не здесь
(`commentRequired` у `DecisionPanel`, см. её же новый проп). Плейсхолдер в этот
момент называет категории, которые нужно объяснить («Введите, за что сумма:
ОС, ТМЦ», см. `commentPlaceholder`). Комментарий уходит в СТРОКУ маршрута этого
согласующего (колонка `Comment` списка согласования) — как обычно, отдельного
поля под него на данных заявки не заводили.
- Суммы сохраняются кнопкой «Сохранить» (`reviewBar`) и тем же `onBeforeDecide`
— при решении несохранённые суммы уйдут вместе с ним, ДО проверки
обязательности комментария (см. `DecisionPanel.decide`).
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение» выше, привязана к
КДП) — сознательно не объединялись, чтобы не менять уже установившееся
поведение существующего поля.
## Секции формы
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма
обучения · парковочная карта · карта СКУД · суммы к списанию · ссылка на заявление)
RequestSection (сотрудник · должность · подразделение · дата расторжения ·
парковочная карта · карта СКУД — без заголовка, `FormGrid`) + отдельная
CollapseSection «Суммы к списанию» (обучение + 5 сумм списания) + ссылка на
заявление
## Точки входа
- rptype: `clearance-form`
@@ -35,23 +35,16 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'TerminationDate',
'ParkingCard',
'AccessCard',
'TrainingAmount',
'Organization',
'Project',
'ProjectId',
'DismissalUrl',
'WriteOffAssetsAmount',
'WriteOffAssetsComment',
'WriteOffPpeAmount',
'WriteOffPpeComment',
'WriteOffAccountableAmount',
'WriteOffAccountableComment',
'WriteOffTrainingAmount',
'WriteOffTrainingComment',
'WriteOffNmaAmount',
'WriteOffNmaComment',
'WriteOffTmcAmount',
'WriteOffTmcComment',
'RequestStatus',
'RequestUniqueCode',
'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 termination = (item.TerminationDate as string) || '';
const amount = item.TrainingAmount;
const data: IFormData = {
...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
ParkingCard: !!item.ParkingCard,
AccessCard: !!item.AccessCard,
TrainingAmount: amount === null || amount === undefined ? '' : String(amount),
// Хранится в WriteOffTrainingAmount, не в одноимённом TrainingAmount — см.
// IFormData.TrainingAmount.
TrainingAmount: numberField(item.WriteOffTrainingAmount),
Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '',
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '',
WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount),
WriteOffAssetsComment: (item.WriteOffAssetsComment as string) || '',
WriteOffPpeAmount: numberField(item.WriteOffPpeAmount),
WriteOffPpeComment: (item.WriteOffPpeComment as string) || '',
WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount),
WriteOffAccountableComment: (item.WriteOffAccountableComment as string) || '',
WriteOffTrainingAmount: numberField(item.WriteOffTrainingAmount),
WriteOffTrainingComment: (item.WriteOffTrainingComment as string) || '',
WriteOffNmaAmount: numberField(item.WriteOffNmaAmount),
WriteOffNmaComment: (item.WriteOffNmaComment as string) || '',
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount),
WriteOffTmcComment: (item.WriteOffTmcComment as string) || ''
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount)
};
return {
@@ -23,7 +23,17 @@ export interface IFormData {
TerminationDate: string;
ParkingCard: boolean;
AccessCard: boolean;
/** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */
/**
* Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* указана. Доступность — КАК И РАНЬШЕ: правит только начальник КДП
* (`hrRecordsEditable`, `approverRights.ts`), не блокируется после ввода (в
* отличие от WRITE_OFF_CATEGORIES ниже). Единственное, что изменилось, —
* КОЛОНКА ХРАНЕНИЯ: пишется и читается через `WriteOffTrainingAmount` (см.
* ClearanceView.saveApproverFields/load.ts), а не через одноимённый
* `TrainingAmount` в списке — тот остался в схеме неиспользуемым (как и
* другие столбцы, из которых код этой сессии перестал писать/читать, см.
* README «Убрано поле…»).
*/
TrainingAmount: string;
/**
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
@@ -43,74 +53,62 @@ export interface IFormData {
DismissalUrl: string;
/**
* Суммы к списанию по обходному листу — 6 независимых пар (сумма +
* обязательный комментарий, «за что конкретно»), см. WRITE_OFF_CATEGORIES.
* «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто =
* не заполнено. КАЖДАЯ пара — независимая: заполнить может любой активный
* согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма
* непустая — пара блокируется НАВСЕГДА, для всех (включая того, кто её ввёл).
* Признак блокировки — сама непустая сумма, отдельного флага в списке нет.
* Суммы к списанию по обходному листу — 5 независимых сумм (компактная сетка
* полей, см. WRITE_OFF_CATEGORIES). Обучение сюда НЕ входит — у него другая
* доступность (см. TrainingAmount выше): эти пять доступны ЛЮБОМУ активному
* согласующему и блокируются НАВСЕГДА после первого ввода (включая того, кто
* ввёл) — признак блокировки: сама непустая сумма, отдельного флага в
* списке нет. «Сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* заполнено.
*
* «За что конкретно» — своего поля НЕТ: заполнил хоть одну сумму — становится
* обязательным СТАНДАРТНЫЙ комментарий решения (DecisionPanel, «Ваше решение»),
* тот же, каким и так сопровождают Согласовать/Вернуть/Отклонить. Он уже
* хранится построчно в списке согласования (колонка Comment на шаге этого
* согласующего) — заводить для него отдельное поле на данных заявки незачем
* (см. ClearanceView — commentRequired/commentPlaceholder у DecisionPanel).
*/
WriteOffAssetsAmount: string;
WriteOffAssetsComment: string;
WriteOffPpeAmount: string;
WriteOffPpeComment: string;
WriteOffAccountableAmount: string;
WriteOffAccountableComment: string;
WriteOffTrainingAmount: string;
WriteOffTrainingComment: string;
WriteOffNmaAmount: string;
WriteOffNmaComment: string;
WriteOffTmcAmount: string;
WriteOffTmcComment: string;
/**
* Колонка ХРАНЕНИЯ для TrainingAmount (см. выше) — НЕ участвует в
* WRITE_OFF_CATEGORIES/WriteOffFieldKey и в общем механизме «любой
* согласующий, блокировка навсегда»: доступность и незаблокируемость у
* «Обучение: сумма» остались как были, изменилась только SharePoint-колонка.
*/
WriteOffTrainingAmount: string;
}
/** Имена всех 12 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */
/** Имена 5 сумм к списанию (не считая WriteOffTrainingAmount — у него своя доступность) — см. IFormData.WriteOff*Amount. */
export type WriteOffFieldKey =
| 'WriteOffAssetsAmount'
| 'WriteOffAssetsComment'
| 'WriteOffPpeAmount'
| 'WriteOffPpeComment'
| 'WriteOffAccountableAmount'
| 'WriteOffAccountableComment'
| 'WriteOffTrainingAmount'
| 'WriteOffTrainingComment'
| 'WriteOffNmaAmount'
| 'WriteOffNmaComment'
| 'WriteOffTmcAmount'
| 'WriteOffTmcComment';
| 'WriteOffTmcAmount';
/**
* Одна из 6 независимых категорий списания — см. IFormData.WriteOff*.
* amountField/commentField сужены до WriteOffFieldKey (а не keyof IFormData
* целиком): у IFormData есть и булевы поля (ParkingCard/AccessCard), и
* `data[cat.amountField]` должен типизироваться строго строкой — иначе
* `.trim()` в валидации и передача в MoneyInput/TextArea не собираются.
* Одна из 5 независимых категорий списания — см. IFormData.WriteOff*Amount.
* amountField сужен до WriteOffFieldKey (а не keyof IFormData целиком): у
* IFormData есть и булевы поля (ParkingCard/AccessCard), и `data[cat.amountField]`
* должен типизироваться строго строкой — иначе `.trim()` в валидации и
* передача в MoneyInput не собираются.
*/
export interface IWriteOffCategory {
key: string;
label: string;
amountField: WriteOffFieldKey;
commentField: WriteOffFieldKey;
}
export const WRITE_OFF_CATEGORIES: IWriteOffCategory[] = [
{ key: 'assets', label: 'ОС', amountField: 'WriteOffAssetsAmount', commentField: 'WriteOffAssetsComment' },
{ key: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount', commentField: 'WriteOffPpeComment' },
{
key: 'accountable',
label: 'Подотчётная сумма',
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' }
{ key: 'assets', label: 'ОС', amountField: 'WriteOffAssetsAmount' },
{ key: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount' },
{ key: 'accountable', label: 'Подотчётная сумма', amountField: 'WriteOffAccountableAmount' },
{ key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount' },
{ key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount' }
];
/** Обновление одного поля формы с сохранением типа значения. */
@@ -143,17 +141,11 @@ export const EMPTY_DATA: IFormData = {
ProjectId: '',
DismissalUrl: '',
WriteOffAssetsAmount: '',
WriteOffAssetsComment: '',
WriteOffPpeAmount: '',
WriteOffPpeComment: '',
WriteOffAccountableAmount: '',
WriteOffAccountableComment: '',
WriteOffTrainingAmount: '',
WriteOffTrainingComment: '',
WriteOffNmaAmount: '',
WriteOffNmaComment: '',
WriteOffTmcAmount: '',
WriteOffTmcComment: ''
WriteOffTmcAmount: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -19,16 +19,10 @@
{ "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" },
{ "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAssetsComment", "title": "Комментарий: ОС", "type": "Note" },
{ "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffPpeComment", "title": "Комментарий: СИЗ", "type": "Note" },
{ "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAccountableComment", "title": "Комментарий: подотчётная сумма", "type": "Note" },
{ "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTrainingComment", "title": "Комментарий: обучение", "type": "Note" },
{ "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffNmaComment", "title": "Комментарий: НМА", "type": "Note" },
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTmcComment", "title": "Комментарий: ТМЦ", "type": "Note" }
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 }
]
}
@@ -1,5 +1,5 @@
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 { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model';
@@ -33,8 +33,10 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
parkingEditable,
printOrgIndex,
writeOffEditableKeys
}) => (
<CollapseSection title="Обходной лист" icon="people">
}) => {
return (
<>
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
@@ -86,16 +88,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onChange={(v) => set('TerminationDate', v)}
/>
</FormField>
{/* Явный readOnly={false} перебивает read-only контекст просмотра (профильный
согласующий правит своё поле); undefined — обычное поведение из FormMode. */}
<FormField label="Обучение: сумма" htmlFor="TrainingAmount">
<MoneyInput
id="TrainingAmount"
value={data.TrainingAmount}
onChange={(v) => set('TrainingAmount', v)}
readOnly={hrRecordsEditable ? false : undefined}
/>
</FormField>
<FormField label="Парковочная карта">
<Toggle
checked={data.ParkingCard}
@@ -112,18 +104,41 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={hrRecordsEditable ? false : undefined}
/>
</FormField>
{/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий,
за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий
(не только профильный, в отличие от отметок выше) — но как только сумма
указана, пара блокируется навсегда для всех, поэтому у незаблокированных
(writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных —
обычное поведение FormMode (просмотр — заблокировано и так). */}
</FormGrid>
{/* Суммы к списанию — своя CollapseSection (тот же стиль заголовка, что
раньше был у общего «Заявка»), объединяет визуально ДВЕ РАЗНЫЕ по
правам вещи:
- TrainingAmount («Обучение») — как раньше: правит только КДП
(hrRecordsEditable), без блокировки после ввода; хранится в
WriteOffTrainingAmount (не одноимённой колонке, см.
IFormData.TrainingAmount), но это деталь хранения, на форму не
влияет. Показывается всегда, даже без writeOffEditableKeys (форма
подачи).
- WRITE_OFF_CATEGORIES (5 сумм) — заполнить может ЛЮБОЙ активный
согласующий (не только профильный), но как только сумма указана,
блокируется НАВСЕГДА для всех, поэтому у незаблокированных
(writeOffEditableKeys.has(cat.key)) readOnly={false}, у остальных
— обычное поведение FormMode. «За что конкретно» у них — своего
поля нет: заполнил хоть одну сумму — обязательным становится
СТАНДАРТНЫЙ комментарий решения в DecisionPanel («Ваше решение»),
см. ClearanceView (commentRequired/commentPlaceholder). У
TrainingAmount этого правила нет — комментарий не требует. Сама
секция видна всегда (Обучение доступно и на форме подачи), а 5
категорий — только на согласовании (writeOffEditableKeys задан). */}
<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 (
<React.Fragment key={cat.key}>
<FormField label={`${cat.label}: сумма`} htmlFor={cat.amountField}>
<FormField key={cat.key} label={cat.label} htmlFor={cat.amountField}>
<MoneyInput
id={cat.amountField}
value={data[cat.amountField]}
@@ -131,22 +146,9 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={editable ? false : undefined}
/>
</FormField>
<FormField
label={`${cat.label}: за что конкретно`}
htmlFor={cat.commentField}
required={editable}
full
>
<TextArea
id={cat.commentField}
value={data[cat.commentField]}
onChange={(v) => set(cat.commentField, v)}
readOnly={editable ? false : undefined}
/>
</FormField>
</React.Fragment>
);
})}
</CollapseSection>
{/* Есть только у листов, созданных автоматически по согласованному заявлению
на увольнение (см. forms/Dismissal/createClearance.ts). */}
<LinkField
@@ -154,5 +156,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
url={data.DismissalUrl}
text="Открыть заявление, по которому создан этот лист"
/>
</CollapseSection>
);
</>
);
};
@@ -3,18 +3,17 @@ import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/
import { ensureEmployee } from '../../shared/employee';
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>;
/**
* Записать сумму(ы)/комментарий(и) к списанию. Пишет РОВНО переданные поля —
* как updateApproverFields, каждая пара независима.
* Записать сумму(ы) к списанию. Пишет РОВНО переданные поля — как
* updateApproverFields, каждая независимо.
*
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО те
* пары, что ещё не заблокированы (см. IFormData.WriteOff* — блокировка по
* непустой сумме) и заполнены целиком (сумма+комментарий вместе, см.
* validateWriteOffPairs) — эта функция сама повторно ничего не проверяет и
* молча перезапишет то, что ей передали.
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО ещё
* не заблокированные суммы (см. IFormData.WriteOff* — блокировка по непустой
* сумме) — эта функция сама повторно ничего не проверяет и молча перезапишет
* то, что ей передали.
*/
export async function updateWriteOffFields(
id: number,
@@ -57,6 +56,11 @@ export interface IApproverEditableFields {
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
* ключи затёрли бы чужие отметки значениями из формы этого согласующего.
*
* fields.TrainingAmount пишется в колонку WriteOffTrainingAmount, а НЕ в
* одноимённый TrainingAmount — см. IFormData.TrainingAmount; доступность
* (только КДП, без блокировки навсегда) не изменилась, изменилась только
* колонка хранения.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан —
* текущая партиция, как раньше.
*/
@@ -70,7 +74,7 @@ export async function updateApproverFields(
if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard;
if (fields.TrainingAmount !== undefined) {
const amount = Number(fields.TrainingAmount);
item.TrainingAmount =
item.WriteOffTrainingAmount =
Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null;
}
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()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee по Id). */
/**
* Поля элемента списка данных из модели формы (+ Employee по Id).
*
* Обычно data.TrainingAmount тут пуст (заявитель его не заполняет, только КДП
* на своём шаге — см. updateApproverFields), но пишем на случай редактирования
* уже созданной заявки: колонка WriteOffTrainingAmount, та же, что и там.
*/
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
const amount = Number(data.TrainingAmount);
return {
@@ -100,7 +110,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TerminationDate: data.TerminationDate || null,
ParkingCard: data.ParkingCard,
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,
Project: data.Project || null,
ProjectId: data.ProjectId || null
@@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
отдельных полей для них нет (были бы дублированием). `data.Organization:
IOrganizationRecord[]` — массив `{company, department, position}`; поле называется
`department` (тот же справочник `Section`, что и раньше — см. следующий абзац),
колонка называется «Структурное подразделение», и то и другое read-only. Должность
колонка называется «Подразделение», и то и другое read-only. Должность
редактируема В ЛЮБОЙ строке (справочник не всегда точен), но маршрут (условие
«IsManager») и обязательность по валидации завязаны ТОЛЬКО на должность ПЕРВОЙ
(основной) записи — `primaryPosition(data.Organization)`; должности остальных
@@ -52,8 +52,6 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
/ `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка,
сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна
компания (см. `parseOrganizationColumn`).
- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет,
свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы.
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через
`MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`,
`min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок
@@ -173,9 +171,8 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
## Секции формы
RequestSection (сотрудник · организация/департамент/должность (таблица —
редактируемая на форме подачи, read-only в просмотре) · самостоятельное
подразделение · последний рабочий день · телефон · комментарий · ссылка на
обходной лист · лицо, принимающее ОС/НМА)
редактируемая на форме подачи, read-only в просмотре) · последний рабочий день ·
телефон · комментарий · ссылка на обходной лист · лицо, принимающее ОС/НМА)
## Точки входа
- rptype: `hr-dismissal`
@@ -27,7 +27,6 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
'Id',
'Position',
'Department',
'StandaloneUnit',
'LastWorkingDay',
'Phone',
'Comment',
@@ -48,7 +47,6 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
const data: IFormData = {
...EMPTY_DATA,
Employee: employee?.EMail || '',
StandaloneUnit: (item.StandaloneUnit as string) || '',
LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
Phone: (item.Phone as string) || '',
Comment: (item.Comment as string) || '',
@@ -86,8 +86,6 @@ export function zipOrganization(
export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
Employee: string;
/** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */
StandaloneUnit: string;
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
LastWorkingDay: string;
/** Контактный телефон (маска, см. PhoneInput). */
@@ -139,7 +137,6 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = {
Employee: '',
StandaloneUnit: '',
LastWorkingDay: '',
Phone: '',
Comment: '',
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Department", "title": "Департамент", "type": "Text" },
{ "internalName": "StandaloneUnit", "title": "Самостоятельное подразделение", "type": "Text" },
{ "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true },
{ "internalName": "Phone", "title": "Контактный телефон", "type": "Text" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
@@ -56,7 +56,7 @@ export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({ records
<thead>
<tr>
<th className={styles.company}>Компания</th>
<th className={styles.department}>Структурное подразделение</th>
<th className={styles.department}>Подразделение</th>
<th className={styles.position}>Должность</th>
{onRemove && <th className={styles.action} />}
</tr>
@@ -1,5 +1,5 @@
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 { isoDatePlusMonths } from '../../../shared/dateUtils';
import { OrganizationsTable } from './OrganizationsTable';
@@ -28,7 +28,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onRemoveOrganization,
assetsAcceptorEditable
}) => (
<CollapseSection title="Заявление на увольнение" icon="document">
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
@@ -55,9 +55,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
}
onRemove={onRemoveOrganization}
/>
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
<TextInput id="StandaloneUnit" value={data.StandaloneUnit} onChange={(v) => set('StandaloneUnit', v)} />
</FormField>
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
@@ -94,5 +91,5 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={assetsAcceptorEditable ? false : true}
/>
</FormField>
</CollapseSection>
</FormGrid>
);
@@ -85,7 +85,6 @@ function buildItem(
Position: organizationColumn(data.Organization, (r) => r.position, 'Должность', TEXT_COLUMN_LIMIT),
Department: organizationColumn(data.Organization, (r) => r.department, 'Департамент', TEXT_COLUMN_LIMIT),
Organization: organizationColumn(data.Organization, (r) => r.company, 'Организация'),
StandaloneUnit: data.StandaloneUnit || null,
LastWorkingDay: data.LastWorkingDay || null,
Phone: data.Phone || null,
Comment: data.Comment || null
@@ -1,12 +1,12 @@
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 { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на отпуск без содержания" icon="people">
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
@@ -65,5 +65,5 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER}
/>
</FormField>
</CollapseSection>
</FormGrid>
);
@@ -1,12 +1,12 @@
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 { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на трудовой отпуск" icon="people">
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
@@ -62,5 +62,5 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER}
/>
</FormField>
</CollapseSection>
</FormGrid>
);
@@ -33,6 +33,17 @@ interface IDecisionPanelProps {
search?: (query: string) => Promise<ISiteUser[]>;
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
onDelegated?: () => void;
/**
* Дополнительное условие обязательности комментария — СВЕРХ обычного
* «возврат/отклонение». Форме есть что объяснять не только при отказе:
* например, «Обходной лист» требует его на ЛЮБОЕ решение, если согласующий
* только что заполнил сумму к списанию — своего поля «за что конкретно» у
* формы нет, вместо него используется этот самый комментарий (см.
* ClearanceView.writeOffTouchedCategories).
*/
commentRequired?: boolean;
/** Переопределить плейсхолдер поля комментария (по умолчанию — про возврат/отклонение). */
commentPlaceholder?: string;
}
// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или
@@ -45,6 +56,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
onBeforeDecide,
search,
onDelegated,
commentRequired,
commentPlaceholder,
}) => {
const [checking, setChecking] = useState<boolean>(true);
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
@@ -84,10 +97,15 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
if (!pendingRow) {
return;
}
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) {
const commentEmpty = comment.trim().length === 0;
if (commentEmpty && (decision === 'return' || decision === 'reject')) {
setError('Для возврата или отклонения укажите комментарий.');
return;
}
if (commentEmpty && commentRequired) {
setError('Укажите комментарий.');
return;
}
setError(null);
setSubmitting(true);
try {
@@ -300,12 +318,12 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}>
<div className={styles.title}>Ваше решение</div>
{error && <Alert variant="err">{error}</Alert>}
<FormField label="Комментарий" htmlFor="decisionComment">
<FormField label="Комментарий" htmlFor="decisionComment" required={commentRequired}>
<TextArea
id="decisionComment"
value={comment}
onChange={setComment}
placeholder="Комментарий (обязателен при возврате или отклонении)"
placeholder={commentPlaceholder || 'Комментарий (обязателен при возврате или отклонении)'}
/>
</FormField>
<div className={styles.actions}>