fix: «Обучение: сумма» — та же доступность, но пишет в WriteOffTrainingAmount

Правка предыдущего коммита: там «Обучение» ошибочно стало одной из
WRITE_OFF_CATEGORIES (доступно любому активному согласующему, блокируется
навсегда) — доступность должна была остаться КАК БЫЛА (только КДП, без
блокировки), просто колонка хранения меняется на WriteOffTrainingAmount.

Откат: WRITE_OFF_CATEGORIES снова 5 категорий (без «Обучение»). TrainingAmount
остаётся отдельным полем формы с прежней доступностью (hrRecordsEditable) —
просто updateApproverFields/buildItem пишут его в колонку WriteOffTrainingAmount,
а load.ts читает оттуда же. Одноимённая колонка TrainingAmount в схеме списка
больше не используется (как и другие столбцы, из которых код этой сессии
перестал писать/читать).

Визуально TrainingAmount остаётся под общим заголовком «Суммы к списанию»
(из прошлого коммита) — только права и правило блокировки у него свои.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-26 14:31:24 +05:00
parent 27e156abf8
commit 5758101243
6 changed files with 71 additions and 40 deletions
@@ -59,11 +59,16 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
`.writeOffLabel`) объединяет визуально ДВЕ РАЗНЫЕ по правам вещи, идущие сразу `.writeOffLabel`) объединяет визуально ДВЕ РАЗНЫЕ по правам вещи, идущие сразу
за подписью: за подписью:
1. **`TrainingAmount`** («Обучение: сумма») — уже существовавшее поле, правит 1. **`TrainingAmount`** («Обучение: сумма») — доступность КАК И РАНЬШЕ: правит
только КДП (`hrRecordsEditable`, `approverRights.ts`), сумма НЕ блокируется только КДП (`hrRecordsEditable`, `approverRights.ts`), сумма НЕ блокируется
после ввода. Заголовок рисуется независимо от него (`writeOffEditableKeys && после ввода, никак не участвует в WRITE_OFF_CATEGORIES/`writeOffTouchedCategories`
<div>…</div>`, а само поле — БЕЗ этого условия), поэтому на форме подачи (не требует комментария решения). Изменилась ТОЛЬКО колонка хранения — пишется
(`ClearanceForm`, `writeOffEditableKeys` не передан) поле видно и без заголовка. и читается через `WriteOffTrainingAmount` (`ClearanceView.saveApproverFields`,
`submit.ts.buildItem`, `load.ts`), а не через одноимённую `TrainingAmount` —
та осталась в схеме списка неиспользуемой. Заголовок рисуется независимо от
поля (`writeOffEditableKeys && <div>…</div>`, а само поле — БЕЗ этого условия),
поэтому на форме подачи (`ClearanceForm`, `writeOffEditableKeys` не передан)
поле видно и без заголовка.
2. **5 НЕЗАВИСИМЫХ сумм**, компактной сеткой (короткие подписи, обычная 2. **5 НЕЗАВИСИМЫХ сумм**, компактной сеткой (короткие подписи, обычная
2-колоночная сетка секции), колонки `WriteOff{Assets,Ppe,Accountable,Nma, 2-колоночная сетка секции), колонки `WriteOff{Assets,Ppe,Accountable,Nma,
Tmc}Amount` (см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная Tmc}Amount` (см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная
@@ -35,12 +35,12 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'TerminationDate', 'TerminationDate',
'ParkingCard', 'ParkingCard',
'AccessCard', 'AccessCard',
'TrainingAmount',
'Organization', 'Organization',
'DismissalUrl', 'DismissalUrl',
'WriteOffAssetsAmount', 'WriteOffAssetsAmount',
'WriteOffPpeAmount', 'WriteOffPpeAmount',
'WriteOffAccountableAmount', 'WriteOffAccountableAmount',
'WriteOffTrainingAmount',
'WriteOffNmaAmount', 'WriteOffNmaAmount',
'WriteOffTmcAmount', 'WriteOffTmcAmount',
'RequestStatus', 'RequestStatus',
@@ -52,7 +52,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,
@@ -62,7 +61,9 @@ 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) || '',
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес. // Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '', DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '',
@@ -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 «Компания — должность» на строку) —
@@ -40,15 +50,12 @@ export interface IFormData {
/** /**
* Суммы к списанию по обходному листу — 5 независимых сумм (компактная сетка * Суммы к списанию по обходному листу — 5 независимых сумм (компактная сетка
* полей, см. WRITE_OFF_CATEGORIES). Обучение сюда НЕ входит — для него уже * полей, см. WRITE_OFF_CATEGORIES). Обучение сюда НЕ входит — у него другая
* есть отдельное поле `TrainingAmount` (см. выше) с ДРУГИМ поведением * доступность (см. TrainingAmount выше): эти пять доступны ЛЮБОМУ активному
* (правит только КДП, не любой согласующий); заводить второе «Обучение» с * согласующему и блокируются НАВСЕГДА после первого ввода (включая того, кто
* иным поведением рядом только путало (полностью совпадающие подписи). * ввёл) — признак блокировки: сама непустая сумма, отдельного флага в
* «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто = * списке нет. «Сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* не заполнено. КАЖДАЯ — независимая: заполнить может любой активный * заполнено.
* согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма
* непустая — блокируется НАВСЕГДА, для всех (включая того, кто её ввёл).
* Признак блокировки — сама непустая сумма, отдельного флага в списке нет.
* *
* «За что конкретно» — своего поля НЕТ: заполнил хоть одну сумму — становится * «За что конкретно» — своего поля НЕТ: заполнил хоть одну сумму — становится
* обязательным СТАНДАРТНЫЙ комментарий решения (DecisionPanel, «Ваше решение»), * обязательным СТАНДАРТНЫЙ комментарий решения (DecisionPanel, «Ваше решение»),
@@ -62,9 +69,16 @@ export interface IFormData {
WriteOffAccountableAmount: string; WriteOffAccountableAmount: string;
WriteOffNmaAmount: string; WriteOffNmaAmount: string;
WriteOffTmcAmount: string; WriteOffTmcAmount: string;
/**
* Колонка ХРАНЕНИЯ для TrainingAmount (см. выше) — НЕ участвует в
* WRITE_OFF_CATEGORIES/WriteOffFieldKey и в общем механизме «любой
* согласующий, блокировка навсегда»: доступность и незаблокируемость у
* «Обучение: сумма» остались как были, изменилась только SharePoint-колонка.
*/
WriteOffTrainingAmount: string;
} }
/** Имена всех 5 сумм к списанию — см. IFormData.WriteOff*Amount. */ /** Имена 5 сумм к списанию (не считая WriteOffTrainingAmount — у него своя доступность) — см. IFormData.WriteOff*Amount. */
export type WriteOffFieldKey = export type WriteOffFieldKey =
| 'WriteOffAssetsAmount' | 'WriteOffAssetsAmount'
| 'WriteOffPpeAmount' | 'WriteOffPpeAmount'
@@ -121,6 +135,7 @@ export const EMPTY_DATA: IFormData = {
WriteOffAssetsAmount: '', WriteOffAssetsAmount: '',
WriteOffPpeAmount: '', WriteOffPpeAmount: '',
WriteOffAccountableAmount: '', WriteOffAccountableAmount: '',
WriteOffTrainingAmount: '',
WriteOffNmaAmount: '', WriteOffNmaAmount: '',
WriteOffTmcAmount: '' WriteOffTmcAmount: ''
}; };
@@ -19,6 +19,7 @@
{ "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 } { "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 }
] ]
@@ -90,24 +90,22 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={hrRecordsEditable ? false : undefined} readOnly={hrRecordsEditable ? false : undefined}
/> />
</FormField> </FormField>
{/* Суммы к списанию — TrainingAmount («Обучение: сумма», правит только {/* Суммы к списанию — TrainingAmount («Обучение: сумма») ПОДВЕДЕНА под
КДП) ПОДВЕДЕНА под тот же заголовок, что и 5 сумм ниже (правит любой тот же заголовок, что и 5 сумм ниже, — визуально одна группа «Суммы к
активный согласующий, см. WRITE_OFF_CATEGORIES) — визуально одна списанию», хотя ДОСТУПНОСТЬ у них РАЗНАЯ:
группа «Суммы к списанию», хотя права на них разные. Без заголовка - TrainingAmount — как раньше: правит только КДП (hrRecordsEditable),
«Обучение» из WRITE_OFF_CATEGORIES читалось бы дублем «Обучение: без блокировки после ввода; хранится в WriteOffTrainingAmount (не
сумма» — их когда-то было два похожих поля, оставили одно (TrainingAmount, одноимённой колонке, см. IFormData.TrainingAmount), но это деталь
см. README «Отдельный механизм от TrainingAmount»), но заголовок группы хранения, на форму не влияет;
пригодился и без второго «Обучение». - WRITE_OFF_CATEGORIES (5 сумм) — заполнить может ЛЮБОЙ активный
Явный readOnly={false} у TrainingAmount перебивает read-only контекст согласующий (не только профильный), но как только сумма указана,
просмотра (профильный согласующий правит своё поле); undefined — блокируется НАВСЕГДА для всех, поэтому у незаблокированных
обычное поведение из FormMode. У сумм из WRITE_OFF_CATEGORIES — (writeOffEditableKeys.has(cat.key)) readOnly={false}, у остальных
заполнить может ЛЮБОЙ активный согласующий (не только профильный), но — обычное поведение FormMode. «За что конкретно» у них — своего
как только сумма указана, блокируется навсегда для всех, поэтому у поля нет: заполнил хоть одну сумму — обязательным становится
незаблокированных (writeOffEditableKeys.has(cat.key)) readOnly={false}, СТАНДАРТНЫЙ комментарий решения в DecisionPanel («Ваше решение»),
у остальных — обычное поведение FormMode. «За что конкретно» — своего см. ClearanceView (commentRequired/commentPlaceholder). У
поля здесь нет: заполнил хоть одну сумму — обязательным становится TrainingAmount этого правила нет — комментарий не требует. */}
СТАНДАРТНЫЙ комментарий решения в DecisionPanel («Ваше решение»), см.
ClearanceView (commentRequired/commentPlaceholder). */}
{writeOffEditableKeys && <div className={styles.writeOffLabel}>Суммы к списанию</div>} {writeOffEditableKeys && <div className={styles.writeOffLabel}>Суммы к списанию</div>}
<FormField label="Обучение: сумма" htmlFor="TrainingAmount"> <FormField label="Обучение: сумма" htmlFor="TrainingAmount">
<MoneyInput <MoneyInput
@@ -3,7 +3,7 @@ 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';
/** 6 сумм к списанию — см. model.ts WRITE_OFF_CATEGORIES. «За что конкретно» — не здесь, см. IFormData. */ /** 5 сумм к списанию — см. model.ts WRITE_OFF_CATEGORIES. «За что конкретно» — не здесь, см. IFormData. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>; export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>;
/** /**
@@ -56,6 +56,11 @@ export interface IApproverEditableFields {
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние * Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
* ключи затёрли бы чужие отметки значениями из формы этого согласующего. * ключи затёрли бы чужие отметки значениями из формы этого согласующего.
* *
* fields.TrainingAmount пишется в колонку WriteOffTrainingAmount, а НЕ в
* одноимённый TrainingAmount — см. IFormData.TrainingAmount; доступность
* (только КДП, без блокировки навсегда) не изменилась, изменилась только
* колонка хранения.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан — * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан —
* текущая партиция, как раньше. * текущая партиция, как раньше.
*/ */
@@ -69,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;
@@ -87,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 {
@@ -99,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
}; };
} }