fix: «за что списание» — переиспользовать стандартный комментарий решения

Правка предыдущего коммита: там для «за что конкретно» завёл отдельное поле
WriteOffComment (с собственной колонкой в списке) — избыточно, раз на экране
уже есть стандартный комментарий решения («Ваше решение» в DecisionPanel),
которым и так сопровождают Согласовать/Вернуть/Отклонить.

Теперь: DecisionPanel получил два новых пропа — commentRequired (сверх обычного
«обязателен при возврате/отклонении» — можно потребовать его на ЛЮБОЕ решение)
и commentPlaceholder (переопределить текст подсказки). ClearanceView передаёt
их, когда есть незаблокированная сумма к списанию с непустым значением
(writeOffTouchedCategories) — комментарий обязателен, а плейсхолдер называет,
за какие категории писать («Введите, за что сумма: ОС, ТМЦ»). Записывается он
туда же, где и всегда — в строку маршрута этого согласующего (Comment списка
согласования), без нового поля на данных заявки.

WriteOffComment (поле, столбец в манифесте, вся связанная логика) убран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-26 12:51:48 +05:00
parent 10963081c6
commit eacf4baeb1
9 changed files with 96 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` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
@@ -169,12 +169,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
[canEditWriteOffs, lockedWriteOffs]
);
// Категории, чью сумму заполняют ПРЯМО СЕЙЧАС (редактируема и не пуста) — от
// них зависит, обязателен ли комментарий решения (DecisionPanel, «Ваше
// решение») и что подставить в его плейсхолдер. «За что конкретно» здесь не
// хранится отдельным полем — вместо этого требуем стандартный комментарий
// решения, который и так сопровождает Согласовать/Вернуть/Отклонить (см.
// commentRequired/commentPlaceholder у DecisionPanel ниже).
const writeOffTouchedCategories = WRITE_OFF_CATEGORIES.filter(
(cat) => editableWriteOffKeys.has(cat.key) && data[cat.amountField].trim() !== ''
);
// Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он
// затёр бы чужие поля значениями, которые видел у себя на экране. Суммы к
// списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys; комментарий
// ОДИН на все суммы (IFormData.WriteOffComment, не пара на каждую категорию)
// — если хоть одна из редактируемых сумм не пуста, комментарий обязателен
// (бросает ДО записи чего бы то ни было).
// списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys.
const saveApproverFields = useCallback(async (): Promise<void> => {
const fields: Partial<IApproverEditableFields> = {};
if (hrRecordsEditable) {
@@ -185,26 +192,16 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
fields.ParkingCard = data.ParkingCard;
}
const touched = WRITE_OFF_CATEGORIES.filter(
(cat) => editableWriteOffKeys.has(cat.key) && data[cat.amountField].trim() !== ''
);
const writeOffFields: Partial<IWriteOffFields> = {};
if (touched.length > 0) {
const comment = data.WriteOffComment.trim();
if (!comment) {
throw new Error('Укажите комментарий — за что списываются суммы.');
}
for (const cat of touched) {
writeOffFields[cat.amountField] = data[cat.amountField];
}
writeOffFields.WriteOffComment = comment;
for (const cat of writeOffTouchedCategories) {
writeOffFields[cat.amountField] = data[cat.amountField];
}
await updateApproverFields(itemId, fields, listId);
if (Object.keys(writeOffFields).length > 0) {
await updateWriteOffFields(itemId, writeOffFields, listId);
}
}, [itemId, listId, hrRecordsEditable, parkingEditable, editableWriteOffKeys, data]);
}, [itemId, listId, hrRecordsEditable, parkingEditable, writeOffTouchedCategories, data]);
const handleSaveFields = async (): Promise<void> => {
setSaveState('saving');
@@ -316,6 +313,17 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
// Отметки сохраняются вместе с решением — иначе согласующий, забывший
// нажать «Сохранить», согласовал бы заявку со старыми значениями.
onBeforeDecide={canEditAnything ? saveApproverFields : undefined}
// Заполнил сумму к списанию — комментарий решения обязателен (не
// только при возврате/отклонении, как обычно): это и есть «за что
// конкретно», отдельного поля под него нет (см. writeOffTouchedCategories).
commentRequired={writeOffTouchedCategories.length > 0}
commentPlaceholder={
writeOffTouchedCategories.length > 0
? `Введите, за что сумма${
writeOffTouchedCategories.length > 1 ? 'ы' : ''
}: ${writeOffTouchedCategories.map((c) => c.label).join(', ')}`
: undefined
}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -58,7 +58,7 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
6 НЕЗАВИСИМЫХ сумм, компактной сеткой (короткие подписи, обычная 2-колоночная
сетка секции), колонки `WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}Amount`
(см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная сумма»,
«Обучение», «НМА», «ТМЦ». Плюс ОДИН общий комментарий на всех — `WriteOffComment`.
«Обучение», «НМА», «ТМЦ».
- **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ
согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`,
@@ -68,26 +68,27 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы
(`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от
текущего `data` — иначе поле блокировалось бы прямо во время набора цифр).
- **Комментарий («за что конкретно») — ОДИН на ВСЕ суммы**, не пара на каждую
категорию: одна Note-колонка (`WriteOffComment`), не шесть. Read-only, пока
нечего заполнять (показывает то, что уже написали раньше); становится
редактируемым и ОБЯЗАТЕЛЬНЫМ ровно тогда, когда хоть одна редактируемая сумма
не пуста (`writeOffTouchedCategories` в `RequestSection`) — `saveApproverFields`
бросает ДО записи чего бы то ни было, если сумма есть, а комментарий пуст.
Плейсхолдер называет категории, для которых он сейчас пишется («Введите, за
что сумма: ОС, ТМЦ»). Следующий согласующий, заполняющий ДРУГУЮ сумму, увидит
и сможет дописать то, что уже есть в поле, — это обычный редактируемый текст,
без автоматического версионирования/склейки.
- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки,
и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним.
- **«За что конкретно» — своего поля НЕТ.** Заполнил хоть одну сумму
(`ClearanceView.writeOffTouchedCategories`) — становится обязательным
СТАНДАРТНЫЙ комментарий решения: то самое поле «Комментарий» в блоке «Ваше
решение» (`DecisionPanel`), которым и так сопровождают Согласовать/Вернуть/
Отклонить — обычно обязательное только при возврате/отклонении, но не здесь
(`commentRequired` у `DecisionPanel`, см. её же новый проп). Плейсхолдер в этот
момент называет категории, которые нужно объяснить («Введите, за что сумма:
ОС, ТМЦ», см. `commentPlaceholder`). Комментарий уходит в СТРОКУ маршрута этого
согласующего (колонка `Comment` списка согласования) — как обычно, отдельного
поля под него на данных заявки не заводили.
- Суммы сохраняются кнопкой «Сохранить» (`reviewBar`) и тем же `onBeforeDecide`
— при решении несохранённые суммы уйдут вместе с ним, ДО проверки
обязательности комментария (см. `DecisionPanel.decide`).
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к
КДП) — сознательно не объединялись, чтобы не менять уже установившееся
поведение существующего поля.
## Секции формы
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма
обучения · парковочная карта · карта СКУД · суммы к списанию (компактно) + общий
комментарий · ссылка на заявление)
обучения · парковочная карта · карта СКУД · суммы к списанию (компактно) ·
ссылка на заявление)
## Точки входа
- rptype: `clearance-form`
@@ -44,7 +44,6 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'WriteOffTrainingAmount',
'WriteOffNmaAmount',
'WriteOffTmcAmount',
'WriteOffComment',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
@@ -73,8 +72,7 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount),
WriteOffTrainingAmount: numberField(item.WriteOffTrainingAmount),
WriteOffNmaAmount: numberField(item.WriteOffNmaAmount),
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount),
WriteOffComment: (item.WriteOffComment as string) || ''
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount)
};
return {
@@ -46,6 +46,13 @@ export interface IFormData {
* ClearanceView.canEditWriteOffs), но как только сумма непустая — блокируется
* НАВСЕГДА, для всех (включая того, кто её ввёл). Признак блокировки — сама
* непустая сумма, отдельного флага в списке нет.
*
* «За что конкретно» — своего поля НЕТ: заполнил хоть одну сумму — становится
* обязательным СТАНДАРТНЫЙ комментарий решения (DecisionPanel, «Ваше решение»),
* тот же, каким и так сопровождают Согласовать/Вернуть/Отклонить. Он уже
* хранится построчно в списке согласования (колонка Comment на шаге этого
* согласующего) — заводить для него отдельное поле на данных заявки незачем
* (см. ClearanceView — commentRequired/commentPlaceholder у DecisionPanel).
*/
WriteOffAssetsAmount: string;
WriteOffPpeAmount: string;
@@ -53,14 +60,6 @@ export interface IFormData {
WriteOffTrainingAmount: string;
WriteOffNmaAmount: string;
WriteOffTmcAmount: string;
/**
* Комментарий «за что конкретно» — ОДИН на ВСЕ суммы к списанию (не пара на
* каждую категорию — раньше было так, но колонок-комментариев на каждую
* категорию не завели: одного общего достаточно). Обязателен, если хоть одна
* из РЕДАКТИРУЕМЫХ сумм не пуста (см. RequestSection.writeOffTouchedCategories),
* и до этого момента read-only — показывает то, что уже написали раньше.
*/
WriteOffComment: string;
}
/** Имена всех 6 сумм к списанию — см. IFormData.WriteOff*Amount. */
@@ -124,8 +123,7 @@ export const EMPTY_DATA: IFormData = {
WriteOffAccountableAmount: '',
WriteOffTrainingAmount: '',
WriteOffNmaAmount: '',
WriteOffTmcAmount: '',
WriteOffComment: ''
WriteOffTmcAmount: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -21,7 +21,6 @@
{ "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffComment", "title": "Комментарий к списанию", "type": "Note" }
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 }
]
}
@@ -1,5 +1,5 @@
import * as React from 'react';
import { CollapseSection, FormField, TextArea, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model';
// Подразделение подтягивается автоматически при выборе сотрудника (см.
@@ -32,13 +32,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
parkingEditable,
writeOffEditableKeys
}) => {
// Категории, чью сумму заполняют ПРЯМО СЕЙЧАС (редактируема и не пуста) —
// от них зависит, показывать ли общий комментарий и что подставить в его
// плейсхолдер (см. IFormData.WriteOffComment в model.ts).
const writeOffTouchedCategories = writeOffEditableKeys
? WRITE_OFF_CATEGORIES.filter((cat) => writeOffEditableKeys.has(cat.key) && data[cat.amountField].trim() !== '')
: [];
return (
<CollapseSection title="Обходной лист" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full>
@@ -112,50 +105,24 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
от отметок выше) — но как только сумма указана, блокируется навсегда
для всех, поэтому у незаблокированных (writeOffEditableKeys.has(cat.key))
есть readOnly={false}, у остальных — обычное поведение FormMode
(просмотр — заблокировано и так). Комментарий — ОДИН на ВСЕ суммы
(не пара на каждую категорию, см. IFormData.WriteOffComment). */}
{writeOffEditableKeys && (
<>
{WRITE_OFF_CATEGORIES.map((cat) => {
const editable = writeOffEditableKeys.has(cat.key);
return (
<FormField key={cat.key} label={cat.label} htmlFor={cat.amountField}>
<MoneyInput
id={cat.amountField}
value={data[cat.amountField]}
onChange={(v) => set(cat.amountField, v)}
readOnly={editable ? false : undefined}
/>
</FormField>
);
})}
{/* 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)}
readOnly={writeOffTouchedCategories.length > 0 ? false : true}
placeholder={
writeOffTouchedCategories.length > 0
? `Введите, за что сумма${
writeOffTouchedCategories.length > 1 ? 'ы' : ''
}: ${writeOffTouchedCategories.map((c) => c.label).join(', ')}`
: undefined
}
(просмотр — заблокировано и так). «За что конкретно» — своего поля
здесь нет: заполнил хоть одну сумму — обязательным становится
СТАНДАРТНЫЙ комментарий решения в DecisionPanel («Ваше решение»), см.
ClearanceView (commentRequired/commentPlaceholder). */}
{writeOffEditableKeys &&
WRITE_OFF_CATEGORIES.map((cat) => {
const editable = writeOffEditableKeys.has(cat.key);
return (
<FormField key={cat.key} label={cat.label} htmlFor={cat.amountField}>
<MoneyInput
id={cat.amountField}
value={data[cat.amountField]}
onChange={(v) => set(cat.amountField, v)}
readOnly={editable ? false : undefined}
/>
</FormField>
)}
</>
)}
);
})}
{/* Есть только у листов, созданных автоматически по согласованному заявлению
на увольнение (см. forms/Dismissal/createClearance.ts). */}
<LinkField
@@ -3,18 +3,17 @@ import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/
import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData, WriteOffFieldKey } from './model';
/** 6 сумм к списанию + общий комментарий — см. model.ts WRITE_OFF_CATEGORIES/IFormData.WriteOffComment. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey | 'WriteOffComment'>;
/** 6 сумм к списанию — см. model.ts WRITE_OFF_CATEGORIES. «За что конкретно» — не здесь, см. IFormData. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>;
/**
* Записать сумму(ы) к списанию и/или общий комментарий. Пишет РОВНО переданные
* поля — как updateApproverFields, каждое независимо.
* Записать сумму(ы) к списанию. Пишет РОВНО переданные поля — как
* updateApproverFields, каждая независимо.
*
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО ещё
* не заблокированные суммы (см. IFormData.WriteOff* — блокировка по непустой
* сумме) и проверенный (непустой, если хоть одна сумма заполняется) комментарий
* — эта функция сама повторно ничего не проверяет и молча перезапишет то, что
* ей передали.
* сумме) — эта функция сама повторно ничего не проверяет и молча перезапишет
* то, что ей передали.
*/
export async function updateWriteOffFields(
id: number,
@@ -33,6 +33,17 @@ interface IDecisionPanelProps {
search?: (query: string) => Promise<ISiteUser[]>;
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
onDelegated?: () => void;
/**
* Дополнительное условие обязательности комментария — СВЕРХ обычного
* «возврат/отклонение». Форме есть что объяснять не только при отказе:
* например, «Обходной лист» требует его на ЛЮБОЕ решение, если согласующий
* только что заполнил сумму к списанию — своего поля «за что конкретно» у
* формы нет, вместо него используется этот самый комментарий (см.
* ClearanceView.writeOffTouchedCategories).
*/
commentRequired?: boolean;
/** Переопределить плейсхолдер поля комментария (по умолчанию — про возврат/отклонение). */
commentPlaceholder?: string;
}
// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или
@@ -45,6 +56,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
onBeforeDecide,
search,
onDelegated,
commentRequired,
commentPlaceholder,
}) => {
const [checking, setChecking] = useState<boolean>(true);
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
@@ -84,10 +97,15 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
if (!pendingRow) {
return;
}
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) {
const commentEmpty = comment.trim().length === 0;
if (commentEmpty && (decision === 'return' || decision === 'reject')) {
setError('Для возврата или отклонения укажите комментарий.');
return;
}
if (commentEmpty && commentRequired) {
setError('Укажите комментарий.');
return;
}
setError(null);
setSubmitting(true);
try {
@@ -300,12 +318,12 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}>
<div className={styles.title}>Ваше решение</div>
{error && <Alert variant="err">{error}</Alert>}
<FormField label="Комментарий" htmlFor="decisionComment">
<FormField label="Комментарий" htmlFor="decisionComment" required={commentRequired}>
<TextArea
id="decisionComment"
value={comment}
onChange={setComment}
placeholder="Комментарий (обязателен при возврате или отклонении)"
placeholder={commentPlaceholder || 'Комментарий (обязателен при возврате или отклонении)'}
/>
</FormField>
<div className={styles.actions}>