Правки #16
+1
-1
@@ -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,39 +33,41 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
parkingEditable,
|
||||
printOrgIndex,
|
||||
writeOffEditableKeys
|
||||
}) => (
|
||||
<CollapseSection title="Обходной лист" icon="people">
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.Employee}
|
||||
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
|
||||
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
|
||||
HR должен суметь ввести должность вручную — иначе маршрут
|
||||
согласования (условие «IsManager» резолвится именно по ней) не
|
||||
соберётся, и список исполнителей ниже останется пустым. */}
|
||||
<FormField label="Должность" htmlFor="Position" required>
|
||||
<TextInput
|
||||
id="Position"
|
||||
value={data.Position}
|
||||
onChange={(v) => set('Position', v)}
|
||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Подразделение" htmlFor="Department">
|
||||
<TextInput
|
||||
id="Department"
|
||||
value={data.Department}
|
||||
onChange={NOOP}
|
||||
readOnly
|
||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||
/>
|
||||
</FormField>
|
||||
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<FormGrid>
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.Employee}
|
||||
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
|
||||
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
|
||||
HR должен суметь ввести должность вручную — иначе маршрут
|
||||
согласования (условие «IsManager» резолвится именно по ней) не
|
||||
соберётся, и список исполнителей ниже останется пустым. */}
|
||||
<FormField label="Должность" htmlFor="Position" required>
|
||||
<TextInput
|
||||
id="Position"
|
||||
value={data.Position}
|
||||
onChange={(v) => set('Position', v)}
|
||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Подразделение" htmlFor="Department">
|
||||
<TextInput
|
||||
id="Department"
|
||||
value={data.Department}
|
||||
onChange={NOOP}
|
||||
readOnly
|
||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||
/>
|
||||
</FormField>
|
||||
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||
<FormField label="Проект" htmlFor="Project">
|
||||
<ProjectPicker
|
||||
id="Project"
|
||||
@@ -78,52 +80,65 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
placeholder="Начните вводить название или код проекта…"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
|
||||
<TextInput
|
||||
id="TerminationDate"
|
||||
type="date"
|
||||
value={data.TerminationDate}
|
||||
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}
|
||||
onChange={(v) => set('ParkingCard', v)}
|
||||
onLabel="Да"
|
||||
readOnly={parkingEditable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Карта СКУД">
|
||||
<Toggle
|
||||
checked={data.AccessCard}
|
||||
onChange={(v) => set('AccessCard', v)}
|
||||
onLabel="Да"
|
||||
readOnly={hrRecordsEditable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий,
|
||||
за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий
|
||||
(не только профильный, в отличие от отметок выше) — но как только сумма
|
||||
указана, пара блокируется навсегда для всех, поэтому у незаблокированных
|
||||
(writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных —
|
||||
обычное поведение FormMode (просмотр — заблокировано и так). */}
|
||||
{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 label="Дата расторжения договора" htmlFor="TerminationDate" required>
|
||||
<TextInput
|
||||
id="TerminationDate"
|
||||
type="date"
|
||||
value={data.TerminationDate}
|
||||
onChange={(v) => set('TerminationDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Парковочная карта">
|
||||
<Toggle
|
||||
checked={data.ParkingCard}
|
||||
onChange={(v) => set('ParkingCard', v)}
|
||||
onLabel="Да"
|
||||
readOnly={parkingEditable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Карта СКУД">
|
||||
<Toggle
|
||||
checked={data.AccessCard}
|
||||
onChange={(v) => set('AccessCard', v)}
|
||||
onLabel="Да"
|
||||
readOnly={hrRecordsEditable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
</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 (
|
||||
<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}>
|
||||
|
||||
Reference in New Issue
Block a user