Feature/vacation edo signing (#23)
Tests / frontend-test (push) Successful in 1m24s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
2026-10-05 08:53:18 +00:00
parent f8ab2a1d71
commit 637e51d7f9
85 changed files with 2152 additions and 171 deletions
@@ -25,11 +25,11 @@
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
## Отличия от «Заявка на отпуск» (hr-vacation)
- Даты с/по вместо «дата начала + количество дней».
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
- Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`).
## Секции формы
RequestSection (сотрудник · должность · дата начала · дата окончания · основание · замещающий)
RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий)
## Точки входа
- rptype: `hr-unpaid-leave`
@@ -13,10 +13,16 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
notifyActiveStep,
useRequestTypeId,
@@ -26,6 +32,7 @@ import {
unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -154,7 +161,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
[context, approval, editId, data.Employee, requestTypeId]
);
@@ -166,8 +173,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Даты корректны, если конец не раньше начала (обе обязательны).
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
// Исполнители фазы «Исполнение» — по маршруту из админки, только чтение.
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
// Продолжительность: целое положительное число календарных дней.
const daysNum = Number(data.DaysCount);
const daysValid = Number.isFinite(daysNum) && daysNum > 0;
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
@@ -176,7 +194,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
const isValid = !!(
data.Employee &&
data.Position.trim() &&
datesValid &&
data.StartDate &&
daysValid &&
isStartDateValid &&
data.Reason.trim()
);
@@ -184,10 +203,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.';
}
if (!datesValid) return 'Дата окончания не может быть раньше даты начала.';
if (!daysValid) return 'Количество дней должно быть больше нуля.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -223,6 +242,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -237,7 +257,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
// не текущей: поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
@@ -259,11 +279,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих.
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
validateSubmit
);
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
@@ -314,7 +345,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
<RequestSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
@@ -8,8 +8,11 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
@@ -70,7 +73,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
@@ -150,6 +154,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -28,7 +28,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'Id',
'Position',
'StartDate',
'EndDate',
'DaysCount',
'Reason',
'Organization',
'Department',
@@ -46,14 +46,14 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const substitute = item.Substitute as { EMail?: string } | undefined;
const start = (item.StartDate as string) || '';
const end = (item.EndDate as string) || '';
const days = item.DaysCount;
const data: IFormData = {
...EMPTY_DATA,
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
StartDate: spDateOnlyToIso(start),
EndDate: spDateOnlyToIso(end),
DaysCount: days === null || days === undefined ? '' : String(days),
Reason: (item.Reason as string) || '',
Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || '',
@@ -12,8 +12,8 @@ export const FORM_ID: RequestTypeId = 'hr-unpaid-leave';
* по возможности = internalName колонок списка данных (см. shared/listTitles.ts).
* Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт
* PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно
* подать и за другого сотрудника. В отличие от «Заявки на отпуск» здесь обе даты
* (с/по) вводятся явно, а DaysCount считается при сохранении.
* подать и за другого сотрудника. Как и в «Заявке на отпуск», даты окончания нет:
* отпуск задаётся началом и количеством дней, чтобы не считать праздники и выходные.
*/
export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
@@ -21,8 +21,8 @@ export interface IFormData {
Position: string;
/** ISO YYYY-MM-DD (input type=date). */
StartDate: string;
/** ISO YYYY-MM-DD (input type=date). */
EndDate: string;
/** Количество календарных дней (строка из input type=number). */
DaysCount: string;
/** Основание «В связи с …». */
Reason: string;
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
@@ -68,7 +68,7 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
StartDate: '',
EndDate: '',
DaysCount: '',
Reason: '',
Substitute: '',
Organization: '',
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
@@ -51,8 +51,8 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="EndDate" required>
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
<FormField label="Количество дней" htmlFor="DaysCount" required>
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
</FormField>
<FormField label="В связи с" htmlFor="Reason" required full>
<TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} />
@@ -16,21 +16,13 @@ function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** Число дней отпуска включительно по датам с/по. Пусто/некорректно → пусто (null). */
function computeDays(startIso: string, endIso: string): number | undefined {
if (!startIso || !endIso) return undefined;
const diff = new Date(endIso).getTime() - new Date(startIso).getTime();
if (!Number.isFinite(diff) || diff < 0) return undefined;
return Math.round(diff / 86400000) + 1;
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(employeeName: string): string {
const now = new Date();
return `Отпуск без содержания — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленный DaysCount). */
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
function buildItem(
data: IFormData,
status: string,
@@ -38,15 +30,14 @@ function buildItem(
employeeName: string,
substituteId: number | undefined
): Record<string, unknown> {
const days = computeDays(data.StartDate, data.EndDate);
const days = Number(data.DaysCount);
return {
Title: buildTitle(employeeName),
RequestStatus: status,
EmployeeId: employeeId,
Position: data.Position || null,
StartDate: data.StartDate || null,
EndDate: data.EndDate || null,
DaysCount: days ?? null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
Reason: data.Reason || null,
SubstituteId: substituteId ?? null,
Organization: data.Organization || null,
@@ -62,7 +53,7 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку без содержания: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет).
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
@@ -75,6 +66,7 @@ export async function saveUnpaidLeaveRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -84,6 +76,6 @@ export async function saveUnpaidLeaveRequest(
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, executors, status);
return result;
}