Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/15
This commit was merged in pull request #15.
This commit is contained in:
@@ -20,12 +20,14 @@ import {
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, WRITE_OFF_CATEGORIES } from './model';
|
||||
import { loadClearanceRequest } from './load';
|
||||
import {
|
||||
clearanceApprovalService,
|
||||
updateApproverFields,
|
||||
IApproverEditableFields
|
||||
updateWriteOffFields,
|
||||
IApproverEditableFields,
|
||||
IWriteOffFields
|
||||
} from './submit';
|
||||
import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
@@ -52,7 +54,15 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
||||
const [parkingEditable, setParkingEditable] = useState<boolean>(false);
|
||||
// Суммы к списанию, в отличие от отметок выше, не привязаны к должности — их
|
||||
// может заполнить ЛЮБОЙ активный согласующий (см. эффект прав ниже).
|
||||
const [canEditWriteOffs, setCanEditWriteOffs] = useState<boolean>(false);
|
||||
// Категории, УЖЕ заблокированные (сумма непустая) НА МОМЕНТ ЗАГРУЗКИ — не
|
||||
// реактивно от текущего data, иначе поле блокировалось бы прямо во время
|
||||
// ввода (набрал сумму → data стал непустым → поле сразу стало бы readOnly).
|
||||
const [lockedWriteOffs, setLockedWriteOffs] = useState<Set<string>>(new Set());
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
@@ -102,6 +112,9 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
setCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setLockedWriteOffs(
|
||||
new Set(WRITE_OFF_CATEGORIES.filter((cat) => !!loaded.data[cat.amountField]).map((cat) => cat.key))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -127,11 +140,15 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
const active = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
|
||||
setHrRecordsEditable(active && canEditHrRecordsFields(pending?.position));
|
||||
setParkingEditable(active && canEditParkingField(pending?.position));
|
||||
// Суммы к списанию — без проверки должности: их вводит тот, кто сейчас
|
||||
// фактически согласует шаг, вне зависимости от отдела.
|
||||
setCanEditWriteOffs(active);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) {
|
||||
setHrRecordsEditable(false);
|
||||
setParkingEditable(false);
|
||||
setCanEditWriteOffs(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -140,9 +157,24 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
|
||||
}, [itemId, approverService, decisionSeq]);
|
||||
|
||||
// Категории списания, которые может править ИМЕННО СЕЙЧАС этот пользователь:
|
||||
// ещё не заблокированы (см. lockedWriteOffs) И у него сейчас активный шаг.
|
||||
// Отдельная переменная, не «canEditWriteOffs && !lockedWriteOffs.has(...)»
|
||||
// на месте использования — нужна и в саму секцию (RequestSection), и здесь.
|
||||
const editableWriteOffKeys = useMemo(
|
||||
() =>
|
||||
canEditWriteOffs
|
||||
? new Set(WRITE_OFF_CATEGORIES.filter((cat) => !lockedWriteOffs.has(cat.key)).map((cat) => cat.key))
|
||||
: new Set<string>(),
|
||||
[canEditWriteOffs, lockedWriteOffs]
|
||||
);
|
||||
|
||||
// Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он
|
||||
// затёр бы чужие поля значениями, которые видел у себя на экране.
|
||||
const saveApproverFields = useCallback((): Promise<void> => {
|
||||
// затёр бы чужие поля значениями, которые видел у себя на экране. Пары сумм к
|
||||
// списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys; частично
|
||||
// заполненная пара (сумма без комментария или наоборот) бросает ДО записи
|
||||
// чего бы то ни было — половинчатая запись хуже отказа сохранить.
|
||||
const saveApproverFields = useCallback(async (): Promise<void> => {
|
||||
const fields: Partial<IApproverEditableFields> = {};
|
||||
if (hrRecordsEditable) {
|
||||
fields.AccessCard = data.AccessCard;
|
||||
@@ -151,15 +183,36 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
if (parkingEditable) {
|
||||
fields.ParkingCard = data.ParkingCard;
|
||||
}
|
||||
return updateApproverFields(itemId, fields, listId);
|
||||
}, [itemId, listId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]);
|
||||
|
||||
const writeOffFields: Partial<IWriteOffFields> = {};
|
||||
for (const cat of WRITE_OFF_CATEGORIES) {
|
||||
if (!editableWriteOffKeys.has(cat.key)) continue;
|
||||
const amount = data[cat.amountField].trim();
|
||||
const comment = data[cat.commentField].trim();
|
||||
if (!amount && !comment) continue; // не трогали — не пишем
|
||||
if (!amount || !comment) {
|
||||
throw new Error(
|
||||
`«${cat.label}»: заполните и сумму, и комментарий (за что конкретно) — или оставьте пару пустой.`
|
||||
);
|
||||
}
|
||||
writeOffFields[cat.amountField] = data[cat.amountField];
|
||||
writeOffFields[cat.commentField] = data[cat.commentField];
|
||||
}
|
||||
|
||||
await updateApproverFields(itemId, fields, listId);
|
||||
if (Object.keys(writeOffFields).length > 0) {
|
||||
await updateWriteOffFields(itemId, writeOffFields, listId);
|
||||
}
|
||||
}, [itemId, listId, hrRecordsEditable, parkingEditable, editableWriteOffKeys, data]);
|
||||
|
||||
const handleSaveFields = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setSaveErrorMessage('');
|
||||
try {
|
||||
await saveApproverFields();
|
||||
setSaveState('saved');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
@@ -175,8 +228,20 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
);
|
||||
}
|
||||
|
||||
const canEditAnything = hrRecordsEditable || parkingEditable;
|
||||
const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable };
|
||||
const canEditAnything = hrRecordsEditable || parkingEditable || canEditWriteOffs;
|
||||
const reviewHints = [
|
||||
hrRecordsEditable && 'отметить карту СКУД и указать сумму за обучение',
|
||||
parkingEditable && 'отметить парковочную карту',
|
||||
editableWriteOffKeys.size > 0 && 'указать суммы к списанию (если применимо)'
|
||||
].filter((hint): hint is string => !!hint);
|
||||
const sectionProps = {
|
||||
data,
|
||||
set,
|
||||
context,
|
||||
hrRecordsEditable,
|
||||
parkingEditable,
|
||||
writeOffEditableKeys: editableWriteOffKeys
|
||||
};
|
||||
|
||||
const notify = (): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
@@ -199,15 +264,15 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
{canEditAnything && (
|
||||
<div className={`${styles.reviewBar} rp-no-print`}>
|
||||
<span className={styles.reviewHint}>
|
||||
{hrRecordsEditable
|
||||
? 'Как согласующий вы можете отметить карту СКУД и указать сумму за обучение.'
|
||||
: 'Как согласующий вы можете отметить парковочную карту.'}
|
||||
{reviewHints.length > 0 && `Как согласующий вы можете ${reviewHints.join('; ')}.`}
|
||||
</span>
|
||||
<Button variant="secondary" onClick={handleSaveFields} disabled={saveState === 'saving'}>
|
||||
Сохранить
|
||||
</Button>
|
||||
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
|
||||
{saveState === 'error' && <span className={styles.saveError}>Ошибка сохранения</span>}
|
||||
{saveState === 'error' && (
|
||||
<span className={styles.saveError}>{saveErrorMessage || 'Ошибка сохранения'}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,8 +39,46 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
|
||||
(в отличие от `EndDate` в «Заявке на отпуск», которая считается).
|
||||
- **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Связь с заявлением на увольнение
|
||||
Обходной лист может появиться **сам, без этой формы**: когда согласовано «Заявление
|
||||
на увольнение» (`hr-dismissal`), портал создаёт лист по нему и сразу отправляет на
|
||||
согласование — см. `forms/Dismissal/createClearance.ts` и README той формы. Такой
|
||||
лист приходит уже с заполненными сотрудником, должностью, подразделением и датой
|
||||
расторжения; отметки (парковка, СКУД, обучение) по-прежнему проставляют профильные
|
||||
согласующие. Подача этой формы руками остаётся — для увольнений, оформленных без
|
||||
заявления в портале.
|
||||
|
||||
Пара связана колонками-гиперссылками: `DismissalUrl` здесь → на заявление,
|
||||
`ClearanceUrl` в заявлении → на лист. Форма их не пишет (заполняет автосоздание либо
|
||||
вручную в SharePoint), но **показывает**: поле «Заявление на увольнение» —
|
||||
`LinkField` из `shared/ui`, read-only, открывается в той же вкладке; у листов,
|
||||
заведённых руками, ссылки нет и поле не рендерится.
|
||||
|
||||
## Суммы к списанию (согласование)
|
||||
6 НЕЗАВИСИМЫХ пар «сумма + обязательный комментарий (за что конкретно)», колонки
|
||||
`WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}{Amount,Comment}` (см. `model.ts
|
||||
WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная сумма», «Обучение», «НМА», «ТМЦ».
|
||||
|
||||
- **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ
|
||||
согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`,
|
||||
фаза «Согласование» — исполнителям недоступно, как и остальным отметкам).
|
||||
- **Каждая пара блокируется НАВСЕГДА после первого ввода** — как только сумма
|
||||
непустая, поле становится read-only для всех, включая того, кто её ввёл.
|
||||
Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы
|
||||
(`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от
|
||||
текущего `data` — иначе поле блокировалось бы прямо во время набора цифр).
|
||||
- **Комментарий обязателен вместе с суммой**: заполнить можно либо обе половины
|
||||
пары, либо ни одной — `saveApproverFields` бросает ДО записи чего бы то ни
|
||||
было, если половина пары осталась пустой.
|
||||
- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки,
|
||||
и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним.
|
||||
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к
|
||||
КДП) — сознательно не объединялись, чтобы не менять уже установившееся
|
||||
поведение существующего поля.
|
||||
|
||||
## Секции формы
|
||||
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД)
|
||||
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма
|
||||
обучения · парковочная карта · карта СКУД · суммы к списанию · ссылка на заявление)
|
||||
|
||||
## Точки входа
|
||||
- rptype: `clearance-form`
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { dataListTitle } from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||
|
||||
/** Число или '' (SharePoint отдаёт null для незаполненного Number). */
|
||||
function numberField(value: unknown): string {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
/** Ф.И.О. увольняемого сотрудника из поля Employee (для заголовка режима чтения). */
|
||||
@@ -32,6 +37,19 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
|
||||
'AccessCard',
|
||||
'TrainingAmount',
|
||||
'Organization',
|
||||
'DismissalUrl',
|
||||
'WriteOffAssetsAmount',
|
||||
'WriteOffAssetsComment',
|
||||
'WriteOffPpeAmount',
|
||||
'WriteOffPpeComment',
|
||||
'WriteOffAccountableAmount',
|
||||
'WriteOffAccountableComment',
|
||||
'WriteOffTrainingAmount',
|
||||
'WriteOffTrainingComment',
|
||||
'WriteOffNmaAmount',
|
||||
'WriteOffNmaComment',
|
||||
'WriteOffTmcAmount',
|
||||
'WriteOffTmcComment',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
'Employee/Title',
|
||||
@@ -52,7 +70,21 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
|
||||
ParkingCard: !!item.ParkingCard,
|
||||
AccessCard: !!item.AccessCard,
|
||||
TrainingAmount: amount === null || amount === undefined ? '' : String(amount),
|
||||
Organization: (item.Organization as string) || ''
|
||||
Organization: (item.Organization as string) || '',
|
||||
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
|
||||
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '',
|
||||
WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount),
|
||||
WriteOffAssetsComment: (item.WriteOffAssetsComment as string) || '',
|
||||
WriteOffPpeAmount: numberField(item.WriteOffPpeAmount),
|
||||
WriteOffPpeComment: (item.WriteOffPpeComment as string) || '',
|
||||
WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount),
|
||||
WriteOffAccountableComment: (item.WriteOffAccountableComment as string) || '',
|
||||
WriteOffTrainingAmount: numberField(item.WriteOffTrainingAmount),
|
||||
WriteOffTrainingComment: (item.WriteOffTrainingComment as string) || '',
|
||||
WriteOffNmaAmount: numberField(item.WriteOffNmaAmount),
|
||||
WriteOffNmaComment: (item.WriteOffNmaComment as string) || '',
|
||||
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount),
|
||||
WriteOffTmcComment: (item.WriteOffTmcComment as string) || ''
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -31,8 +31,84 @@ export interface IFormData {
|
||||
* read-only (см. shared/ui/OrganizationField).
|
||||
*/
|
||||
Organization: string;
|
||||
/**
|
||||
* Ссылка на заявление на увольнение, по которому создан этот лист — колонка-
|
||||
* гиперссылка `DismissalUrl`. Форма её не пишет (заполняет автосоздание, см.
|
||||
* forms/Dismissal/createClearance.ts) и не даёт править — только показывает.
|
||||
*/
|
||||
DismissalUrl: string;
|
||||
|
||||
/**
|
||||
* Суммы к списанию по обходному листу — 6 независимых пар (сумма +
|
||||
* обязательный комментарий, «за что конкретно»), см. WRITE_OFF_CATEGORIES.
|
||||
* «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто =
|
||||
* не заполнено. КАЖДАЯ пара — независимая: заполнить может любой активный
|
||||
* согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма
|
||||
* непустая — пара блокируется НАВСЕГДА, для всех (включая того, кто её ввёл).
|
||||
* Признак блокировки — сама непустая сумма, отдельного флага в списке нет.
|
||||
*/
|
||||
WriteOffAssetsAmount: string;
|
||||
WriteOffAssetsComment: string;
|
||||
WriteOffPpeAmount: string;
|
||||
WriteOffPpeComment: string;
|
||||
WriteOffAccountableAmount: string;
|
||||
WriteOffAccountableComment: string;
|
||||
WriteOffTrainingAmount: string;
|
||||
WriteOffTrainingComment: string;
|
||||
WriteOffNmaAmount: string;
|
||||
WriteOffNmaComment: string;
|
||||
WriteOffTmcAmount: string;
|
||||
WriteOffTmcComment: string;
|
||||
}
|
||||
|
||||
/** Имена всех 12 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */
|
||||
export type WriteOffFieldKey =
|
||||
| 'WriteOffAssetsAmount'
|
||||
| 'WriteOffAssetsComment'
|
||||
| 'WriteOffPpeAmount'
|
||||
| 'WriteOffPpeComment'
|
||||
| 'WriteOffAccountableAmount'
|
||||
| 'WriteOffAccountableComment'
|
||||
| 'WriteOffTrainingAmount'
|
||||
| 'WriteOffTrainingComment'
|
||||
| 'WriteOffNmaAmount'
|
||||
| 'WriteOffNmaComment'
|
||||
| 'WriteOffTmcAmount'
|
||||
| 'WriteOffTmcComment';
|
||||
|
||||
/**
|
||||
* Одна из 6 независимых категорий списания — см. IFormData.WriteOff*.
|
||||
* amountField/commentField сужены до WriteOffFieldKey (а не keyof IFormData
|
||||
* целиком): у IFormData есть и булевы поля (ParkingCard/AccessCard), и
|
||||
* `data[cat.amountField]` должен типизироваться строго строкой — иначе
|
||||
* `.trim()` в валидации и передача в MoneyInput/TextArea не собираются.
|
||||
*/
|
||||
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' }
|
||||
];
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
@@ -56,7 +132,20 @@ export const EMPTY_DATA: IFormData = {
|
||||
ParkingCard: false,
|
||||
AccessCard: false,
|
||||
TrainingAmount: '',
|
||||
Organization: ''
|
||||
Organization: '',
|
||||
DismissalUrl: '',
|
||||
WriteOffAssetsAmount: '',
|
||||
WriteOffAssetsComment: '',
|
||||
WriteOffPpeAmount: '',
|
||||
WriteOffPpeComment: '',
|
||||
WriteOffAccountableAmount: '',
|
||||
WriteOffAccountableComment: '',
|
||||
WriteOffTrainingAmount: '',
|
||||
WriteOffTrainingComment: '',
|
||||
WriteOffNmaAmount: '',
|
||||
WriteOffNmaComment: '',
|
||||
WriteOffTmcAmount: '',
|
||||
WriteOffTmcComment: ''
|
||||
};
|
||||
|
||||
/** Плейсхолдер для поля выбора сотрудника. */
|
||||
|
||||
@@ -12,6 +12,21 @@
|
||||
{ "internalName": "ParkingCard", "title": "Парковочная карта", "type": "Boolean" },
|
||||
{ "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" },
|
||||
{ "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
|
||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||
import { CollapseSection, FormField, TextArea, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model';
|
||||
|
||||
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
||||
// ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный
|
||||
@@ -12,6 +12,14 @@ interface IRequestSectionProps extends ISectionProps {
|
||||
hrRecordsEditable?: boolean;
|
||||
/** Согласующий из отдела подбора правит парковочную карту. */
|
||||
parkingEditable?: boolean;
|
||||
/**
|
||||
* Ключи категорий списания (см. WRITE_OFF_CATEGORIES), которые ЭТОТ
|
||||
* пользователь может сейчас заполнить — присутствие пропа (даже пустым
|
||||
* Set'ом) включает блок сумм к списанию; не задан вовсе (форма подачи, см.
|
||||
* ClearanceForm) — блок не показывается: до первого решения списывать
|
||||
* нечего. См. ClearanceView.editableWriteOffKeys.
|
||||
*/
|
||||
writeOffEditableKeys?: Set<string>;
|
||||
}
|
||||
|
||||
/** Единственная секция формы «Обходной лист»: увольняемый, должность, подразделение, даты, карты, обучение. */
|
||||
@@ -21,7 +29,8 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
context,
|
||||
onEmployeeChange,
|
||||
hrRecordsEditable,
|
||||
parkingEditable
|
||||
parkingEditable,
|
||||
writeOffEditableKeys
|
||||
}) => (
|
||||
<CollapseSection title="Обходной лист" icon="people">
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
@@ -89,5 +98,47 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
readOnly={hrRecordsEditable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий,
|
||||
за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий
|
||||
(не только профильный, в отличие от отметок выше) — но как только сумма
|
||||
указана, пара блокируется навсегда для всех, поэтому у незаблокированных
|
||||
(writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных —
|
||||
обычное поведение FormMode (просмотр — заблокировано и так). */}
|
||||
{writeOffEditableKeys &&
|
||||
WRITE_OFF_CATEGORIES.map((cat) => {
|
||||
const editable = writeOffEditableKeys.has(cat.key);
|
||||
return (
|
||||
<React.Fragment key={cat.key}>
|
||||
<FormField label={`${cat.label}: сумма`} htmlFor={cat.amountField}>
|
||||
<MoneyInput
|
||||
id={cat.amountField}
|
||||
value={data[cat.amountField]}
|
||||
onChange={(v) => set(cat.amountField, v)}
|
||||
readOnly={editable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={`${cat.label}: за что конкретно`}
|
||||
htmlFor={cat.commentField}
|
||||
required={editable}
|
||||
full
|
||||
>
|
||||
<TextArea
|
||||
id={cat.commentField}
|
||||
value={data[cat.commentField]}
|
||||
onChange={(v) => set(cat.commentField, v)}
|
||||
readOnly={editable ? false : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Есть только у листов, созданных автоматически по согласованному заявлению
|
||||
на увольнение (см. forms/Dismissal/createClearance.ts). */}
|
||||
<LinkField
|
||||
label="Заявление на увольнение"
|
||||
url={data.DismissalUrl}
|
||||
text="Открыть заявление, по которому создан этот лист"
|
||||
/>
|
||||
</CollapseSection>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||
import { ensureEmployee } from '../../shared/employee';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
import { FORM_ID, IFormData, WriteOffFieldKey } from './model';
|
||||
|
||||
/** Одна из 12 колонок сумм/комментариев к списанию — см. model.ts WRITE_OFF_CATEGORIES. */
|
||||
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>;
|
||||
|
||||
/**
|
||||
* Записать сумму(ы)/комментарий(и) к списанию. Пишет РОВНО переданные поля —
|
||||
* как updateApproverFields, каждая пара независима.
|
||||
*
|
||||
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО те
|
||||
* пары, что ещё не заблокированы (см. IFormData.WriteOff* — блокировка по
|
||||
* непустой сумме) и заполнены целиком (сумма+комментарий вместе, см.
|
||||
* validateWriteOffPairs) — эта функция сама повторно ничего не проверяет и
|
||||
* молча перезапишет то, что ей передали.
|
||||
*/
|
||||
export async function updateWriteOffFields(
|
||||
id: number,
|
||||
fields: Partial<IWriteOffFields>,
|
||||
listId?: string
|
||||
): Promise<void> {
|
||||
const item: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(fields) as Array<keyof IWriteOffFields>) {
|
||||
const raw = fields[key];
|
||||
if (raw === undefined) continue;
|
||||
if (key.endsWith('Amount')) {
|
||||
const amount = Number(raw);
|
||||
item[key] = Number.isFinite(amount) && raw.trim() !== '' ? amount : null;
|
||||
} else {
|
||||
item[key] = raw || null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(item).length === 0) return;
|
||||
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
|
||||
Reference in New Issue
Block a user