Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 1m9s
Tests / frontend-test (push) Successful in 1m9s
This commit is contained in:
+1
-1
@@ -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,8 +33,10 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
parkingEditable,
|
parkingEditable,
|
||||||
printOrgIndex,
|
printOrgIndex,
|
||||||
writeOffEditableKeys
|
writeOffEditableKeys
|
||||||
}) => (
|
}) => {
|
||||||
<CollapseSection title="Обходной лист" icon="people">
|
return (
|
||||||
|
<>
|
||||||
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
@@ -86,16 +88,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
onChange={(v) => set('TerminationDate', v)}
|
onChange={(v) => set('TerminationDate', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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="Парковочная карта">
|
<FormField label="Парковочная карта">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={data.ParkingCard}
|
checked={data.ParkingCard}
|
||||||
@@ -112,18 +104,41 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
readOnly={hrRecordsEditable ? false : undefined}
|
readOnly={hrRecordsEditable ? false : undefined}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий,
|
</FormGrid>
|
||||||
за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий
|
{/* Суммы к списанию — своя CollapseSection (тот же стиль заголовка, что
|
||||||
(не только профильный, в отличие от отметок выше) — но как только сумма
|
раньше был у общего «Заявка»), объединяет визуально ДВЕ РАЗНЫЕ по
|
||||||
указана, пара блокируется навсегда для всех, поэтому у незаблокированных
|
правам вещи:
|
||||||
(writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных —
|
- TrainingAmount («Обучение») — как раньше: правит только КДП
|
||||||
обычное поведение FormMode (просмотр — заблокировано и так). */}
|
(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 &&
|
{writeOffEditableKeys &&
|
||||||
WRITE_OFF_CATEGORIES.map((cat) => {
|
WRITE_OFF_CATEGORIES.map((cat) => {
|
||||||
const editable = writeOffEditableKeys.has(cat.key);
|
const editable = writeOffEditableKeys.has(cat.key);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={cat.key}>
|
<FormField key={cat.key} label={cat.label} htmlFor={cat.amountField}>
|
||||||
<FormField 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}
|
|
||||||
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). */}
|
на увольнение (см. 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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user