feat: один общий комментарий к списанию вместо шести, «Подразделение» вместо «Структурное»

Обходной лист: 6 отдельных Comment-колонок (по одной на категорию списания)
консолидированы в одну WriteOffComment — комментарий и так один по смыслу
(«за что списываем»), дублировать текст в 6 колонок не было смысла. IWriteOffCategory
больше не хранит commentField, WriteOffFieldKey сузился до 6 сумм.

Заявление на увольнение: заголовок колонки таблицы организаций — «Подразделение»
вместо «Структурное подразделение» (данные по-прежнему из Section справочника,
только подпись короче).

Со схемой ничего руками не делал — команда для провижининга ниже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-26 12:43:37 +05:00
parent cd1fd76c76
commit 10963081c6
10 changed files with 71 additions and 108 deletions
+1 -1
View File
@@ -45,7 +45,7 @@
| [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ |
| [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ |
| [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 6 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, обучение, НМА, ТМЦ) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — общий комментарий «за что» становится обязательным | ✅ |
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 6 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, обучение, НМА, ТМЦ) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — ОДИН общий комментарий «за что» становится обязательным | ✅ |
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
@@ -172,11 +172,9 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
// Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он
// затёр бы чужие поля значениями, которые видел у себя на экране. Суммы к
// списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys; комментарий
// ОДИН на все суммы, заполняемые СЕЙЧАС (не пара на каждую, см. IFormData.
// WriteOffComment) — если хоть одна из них не пуста, комментарий обязателен
// (бросает ДО записи чего бы то ни было) и при записи дублируется в
// Comment-колонку КАЖДОЙ такой категории — так каждая остаётся
// прокомментированной в самом списке.
// ОДИН на все суммы (IFormData.WriteOffComment, не пара на каждую категорию)
// — если хоть одна из редактируемых сумм не пуста, комментарий обязателен
// (бросает ДО записи чего бы то ни было).
const saveApproverFields = useCallback(async (): Promise<void> => {
const fields: Partial<IApproverEditableFields> = {};
if (hrRecordsEditable) {
@@ -198,8 +196,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
}
for (const cat of touched) {
writeOffFields[cat.amountField] = data[cat.amountField];
writeOffFields[cat.commentField] = comment;
}
writeOffFields.WriteOffComment = comment;
}
await updateApproverFields(itemId, fields, listId);
@@ -55,10 +55,10 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
заведённых руками, ссылки нет и поле не рендерится.
## Суммы к списанию (согласование)
6 НЕЗАВИСИМЫХ сумм, компактной сеткой (короткие подписи, без комментария под
каждой — см. ниже), колонки `WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}
{Amount,Comment}` (см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная
сумма», «Обучение», «НМА», «ТМЦ».
6 НЕЗАВИСИМЫХ сумм, компактной сеткой (короткие подписи, обычная 2-колоночная
сетка секции), колонки `WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}Amount`
(см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная сумма»,
«Обучение», «НМА», «ТМЦ». Плюс ОДИН общий комментарий на всех — `WriteOffComment`.
- **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ
согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`,
@@ -68,15 +68,16 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы
(`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от
текущего `data` — иначе поле блокировалось бы прямо во время набора цифр).
- **Комментарий — ОДИН на все суммы, заполняемые в ЭТОМ сохранении** (не пара на
каждую категорию, как раньше): появляется под сеткой, только когда хоть одна
редактируемая сумма не пуста (`writeOffTouchedCategories` в `RequestSection`),
и обязателен в этот момент — `saveApproverFields` бросает ДО записи чего бы то
ни было, если сумма есть, а комментарий пуст. Плейсхолдер называет категории,
для которых он сейчас пишется («Введите, за что сумма: ОС, ТМЦ»). При записи
этот ОДИН текст дублируется в Comment-колонку КАЖДОЙ заполняемой сейчас
категории — так индивидуальный комментарий по категории в списке остаётся, а
форма не заставляет писать одно и то же под каждой суммой отдельно.
- **Комментарий («за что конкретно») — ОДИН на ВСЕ суммы**, не пара на каждую
категорию: одна Note-колонка (`WriteOffComment`), не шесть. Read-only, пока
нечего заполнять (показывает то, что уже написали раньше); становится
редактируемым и ОБЯЗАТЕЛЬНЫМ ровно тогда, когда хоть одна редактируемая сумма
не пуста (`writeOffTouchedCategories` в `RequestSection`) — `saveApproverFields`
бросает ДО записи чего бы то ни было, если сумма есть, а комментарий пуст.
Плейсхолдер называет категории, для которых он сейчас пишется («Введите, за
что сумма: ОС, ТМЦ»). Следующий согласующий, заполняющий ДРУГУЮ сумму, увидит
и сможет дописать то, что уже есть в поле, — это обычный редактируемый текст,
без автоматического версионирования/склейки.
- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки,
и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним.
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к
@@ -39,17 +39,12 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'Organization',
'DismissalUrl',
'WriteOffAssetsAmount',
'WriteOffAssetsComment',
'WriteOffPpeAmount',
'WriteOffPpeComment',
'WriteOffAccountableAmount',
'WriteOffAccountableComment',
'WriteOffTrainingAmount',
'WriteOffTrainingComment',
'WriteOffNmaAmount',
'WriteOffNmaComment',
'WriteOffTmcAmount',
'WriteOffTmcComment',
'WriteOffComment',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
@@ -74,17 +69,12 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
// Гиперссылка приходит объектом {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) || ''
WriteOffComment: (item.WriteOffComment as string) || ''
};
return {
@@ -46,80 +46,52 @@ export interface IFormData {
* ClearanceView.canEditWriteOffs), но как только сумма непустая — блокируется
* НАВСЕГДА, для всех (включая того, кто её ввёл). Признак блокировки — сама
* непустая сумма, отдельного флага в списке нет.
*
* Комментарий («за что конкретно») — ОБЩИЙ на все суммы, вводимые ОДНИМ
* сохранением (WriteOffComment, ниже), не отдельная пара на каждую категорию:
* при записи он дублируется в Comment-колонку КАЖДОЙ категории, чью сумму
* заполнили именно сейчас (см. ClearanceView.saveApproverFields) — так каждая
* категория остаётся комментированной в самом списке, а форма не заставляет
* писать одно и то же по кругу под каждой суммой.
*/
WriteOffAssetsAmount: string;
WriteOffAssetsComment: string;
WriteOffPpeAmount: string;
WriteOffPpeComment: string;
WriteOffAccountableAmount: string;
WriteOffAccountableComment: string;
WriteOffTrainingAmount: string;
WriteOffTrainingComment: string;
WriteOffNmaAmount: string;
WriteOffNmaComment: string;
WriteOffTmcAmount: string;
WriteOffTmcComment: string;
/**
* Комментарий к сумме(ам), заполняемым ПРЯМО СЕЙЧАС (не из списка — вводится
* заново при каждом сохранении, см. комментарий выше). Обязателен, если хоть
* одна из редактируемых сумм не пуста (см. RequestSection).
* Комментарий «за что конкретно» — ОДИН на ВСЕ суммы к списанию (не пара на
* каждую категорию — раньше было так, но колонок-комментариев на каждую
* категорию не завели: одного общего достаточно). Обязателен, если хоть одна
* из РЕДАКТИРУЕМЫХ сумм не пуста (см. RequestSection.writeOffTouchedCategories),
* и до этого момента read-only — показывает то, что уже написали раньше.
*/
WriteOffComment: string;
}
/** Имена всех 12 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */
/** Имена всех 6 сумм к списанию — см. 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 не собираются.
* Одна из 6 независимых категорий списания — см. 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: 'training', label: 'Обучение', amountField: 'WriteOffTrainingAmount' },
{ key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount' },
{ key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount' }
];
/** Обновление одного поля формы с сохранением типа значения. */
@@ -148,17 +120,11 @@ export const EMPTY_DATA: IFormData = {
Organization: '',
DismissalUrl: '',
WriteOffAssetsAmount: '',
WriteOffAssetsComment: '',
WriteOffPpeAmount: '',
WriteOffPpeComment: '',
WriteOffAccountableAmount: '',
WriteOffAccountableComment: '',
WriteOffTrainingAmount: '',
WriteOffTrainingComment: '',
WriteOffNmaAmount: '',
WriteOffNmaComment: '',
WriteOffTmcAmount: '',
WriteOffTmcComment: '',
WriteOffComment: ''
};
@@ -17,16 +17,11 @@
{ "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": "WriteOffComment", "title": "Комментарий к списанию", "type": "Note" }
]
}
@@ -112,8 +112,8 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
от отметок выше) — но как только сумма указана, блокируется навсегда
для всех, поэтому у незаблокированных (writeOffEditableKeys.has(cat.key))
есть readOnly={false}, у остальных — обычное поведение FormMode
(просмотр — заблокировано и так). Комментарий — ОДИН на все суммы,
вводимые сейчас (см. WRITE_OFF_CATEGORIES/WriteOffComment в model.ts). */}
(просмотр — заблокировано и так). Комментарий — ОДИН на ВСЕ суммы
(не пара на каждую категорию, см. IFormData.WriteOffComment). */}
{writeOffEditableKeys && (
<>
{WRITE_OFF_CATEGORIES.map((cat) => {
@@ -129,15 +129,28 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
</FormField>
);
})}
{writeOffTouchedCategories.length > 0 && (
<FormField label="Комментарий: за что списание" htmlFor="WriteOffComment" required full>
{/* Read-only, если сейчас нечего заполнять (нет свежих сумм) — просто
показывает, что написали раньше; редактируема и обязательна
ровно тогда, когда есть непустая сумма, которую надо объяснить. */}
{(writeOffTouchedCategories.length > 0 || data.WriteOffComment) && (
<FormField
label="Комментарий: за что списание"
htmlFor="WriteOffComment"
required={writeOffTouchedCategories.length > 0}
full
>
<TextArea
id="WriteOffComment"
value={data.WriteOffComment}
onChange={(v) => set('WriteOffComment', v)}
placeholder={`Введите, за что сумма${
writeOffTouchedCategories.length > 1 ? 'ы' : ''
}: ${writeOffTouchedCategories.map((c) => c.label).join(', ')}`}
readOnly={writeOffTouchedCategories.length > 0 ? false : true}
placeholder={
writeOffTouchedCategories.length > 0
? `Введите, за что сумма${
writeOffTouchedCategories.length > 1 ? 'ы' : ''
}: ${writeOffTouchedCategories.map((c) => c.label).join(', ')}`
: undefined
}
/>
</FormField>
)}
@@ -3,18 +3,18 @@ import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/
import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData, WriteOffFieldKey } from './model';
/** Одна из 12 колонок сумм/комментариев к списанию — см. model.ts WRITE_OFF_CATEGORIES. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>;
/** 6 сумм к списанию + общий комментарий — см. model.ts WRITE_OFF_CATEGORIES/IFormData.WriteOffComment. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey | 'WriteOffComment'>;
/**
* Записать сумму(ы)/комментарий(и) к списанию. Пишет РОВНО переданные поля —
* как updateApproverFields, каждая пара независима.
* Записать сумму(ы) к списанию и/или общий комментарий. Пишет РОВНО переданные
* поля — как updateApproverFields, каждое независимо.
*
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО те
* пары, что ещё не заблокированы (см. IFormData.WriteOff* — блокировка по
* непустой сумме) и заполнены целиком (сумма+комментарий вместе, см.
* validateWriteOffPairs) — эта функция сама повторно ничего не проверяет и
* молча перезапишет то, что ей передали.
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО ещё
* не заблокированные суммы (см. IFormData.WriteOff* — блокировка по непустой
* сумме) и проверенный (непустой, если хоть одна сумма заполняется) комментарий
* — эта функция сама повторно ничего не проверяет и молча перезапишет то, что
* ей передали.
*/
export async function updateWriteOffFields(
id: number,
@@ -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)`; должности остальных
@@ -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>