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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user