@@ -116,6 +124,16 @@ export function RequestDetailPanel({
{hasActions ? (
+ {canEditVacationTerms ? (
+
+ ) : null}
{canProcess ? (
<>
+ )}
+ >
+ {loading ? (
+
+ Загрузка условий…
+
+ ) : error && !context ? (
+
{error}
+ ) : context ? (
+
+
+
+
+
+
{employeeName}
+
{formatRequestPeriod(request)}
+
+
+ {context.total_days} дн.
+
+
+
+
+
+ Оплата отпуска
+
+ {([
+ ["full", "Оплачиваемый"],
+ ["partial", "Частично"],
+ ["unpaid", "Без оплаты"],
+ ] as const).map(([value, label]) => (
+ {
+ setMode(value);
+ if (value === "unpaid") setRecordToFinance(false);
+ }}
+ className={`${mode === value ? "app-selected app-accent-text" : "app-action-secondary"} min-w-0 rounded-xl px-2 py-2.5 text-xs font-medium sm:text-sm`}
+ >
+ {label}
+
+ ))}
+
+ {mode === "partial" ? (
+
+ ) : null}
+
+ Оплачиваемых: {paidDays} · без оплаты: {Math.max(context.total_days - paidDays, 0)}
+
+
+
+
+
+
Статистика сотрудника
+
{
+ setLoading(true);
+ setError(null);
+ setPeriod(value as RequestVacationStatsPeriod);
+ }}
+ ariaLabel="Период статистики отпусков"
+ variant="badge"
+ />
+
+
+
+
+
+
+
+
+
setRecordToFinance((value) => !value)}
+ className={`${recordToFinance ? "app-selected" : "app-surface-muted"} flex w-full items-start gap-3 rounded-xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-50`}
+ >
+
+
+
+
+ Передать в финансы
+
+ {financeInstruction?.amount
+ ? `Начисление ${financeInstruction.amount} ₽: ${paidDays} дн. × ${financeInstruction.daily_rate} ₽.`
+ : `Создать начисление отпускных за ${paidDays} дн. по дневной ставке сотрудника.`}
+
+
+
+
+
+ {error ?
{error}
: null}
+
+ ) : null}
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: number }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts
index 5019bffc..a12a97bc 100644
--- a/frontend/src/hooks/useRequestsPage.ts
+++ b/frontend/src/hooks/useRequestsPage.ts
@@ -169,6 +169,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
/* form */
const [createOpen, setCreateOpen] = useState(false);
const [editingRequest, setEditingRequest] = useState
(null);
+ const [vacationTermsRequest, setVacationTermsRequest] = useState(null);
const [form, setForm] = useState(createEmptyForm);
/* filters */
@@ -485,11 +486,25 @@ export function useRequestsPage(_userId: number | null | undefined) {
}, [nextPage, loadingMore, buildRequestParams]);
const handleApprove = async (id: number) => {
- try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
+ try {
+ setBusyKey(`approve-${id}`);
+ setActionError(null);
+ const updated = await apiClient.approveRequest(id) as Request;
+ applyUpdatedRequest(updated);
+ if (updated.type === "vacation" && updated.vacation_terms_required) {
+ setVacationTermsRequest(updated);
+ }
+ await refreshRequests();
+ }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
finally { setBusyKey(null); }
};
+ const handleVacationTermsSaved = useCallback((updated: Request) => {
+ applyUpdatedRequest(updated);
+ void refreshRequests();
+ }, [applyUpdatedRequest, refreshRequests]);
+
const handleReject = async (id: number) => {
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
@@ -662,6 +677,10 @@ export function useRequestsPage(_userId: number | null | undefined) {
/* UI */
attachmentPreview, setAttachmentPreview,
+ vacationTermsRequest,
+ openVacationTerms: setVacationTermsRequest,
+ closeVacationTerms: () => setVacationTermsRequest(null),
+ handleVacationTermsSaved,
detailsRequest, setDetailsRequest,
loading, refreshing, loadingMore, error,
actionError, actionSuccess,
diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts
index 180ce5c8..b98202e6 100644
--- a/frontend/src/lib/api/finance.ts
+++ b/frontend/src/lib/api/finance.ts
@@ -7,6 +7,11 @@ export type PayrollLineKind =
| "deduction"
| "payment";
+export type PayrollComponentPeriodScope =
+ | "current_only"
+ | "current_and_future"
+ | "all";
+
export type PayrollPeriodSummary = {
code: string;
name: string;
@@ -581,6 +586,15 @@ export type PayrollComponent = {
requires_reason: boolean;
is_active: boolean;
display_order: number;
+ period_scope: PayrollComponentPeriodScope;
+ scope_period_id: number | null;
+};
+
+export type PayrollCustomComponentWrite = {
+ name: string;
+ kind: PayrollLineKind;
+ requires_reason: boolean;
+ period_scope: PayrollComponentPeriodScope;
};
export type PayrollAdminPayRate = {
@@ -1029,6 +1043,10 @@ export function createFinanceApi(request: RequestFn) {
),
getPayrollAdminWorkspace: (periodId?: number): Promise =>
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
+ createPayrollAdminComponent: (
+ payload: PayrollCustomComponentWrite & { period_id: number },
+ ): Promise =>
+ request(`${adminPrefix}/components/`, jsonOptions("POST", payload)),
getPayrollAdminPeriods: (): Promise> =>
request(`${adminPrefix}/periods/`),
getPayrollAdminPeriodTable: (periodId: number): Promise =>
diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts
index 5d9e416c..a11cee66 100644
--- a/frontend/src/lib/api/requests.ts
+++ b/frontend/src/lib/api/requests.ts
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, User } from '@/types/api';
+import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, RequestVacationStatsPeriod, RequestVacationTermsContext, User } from '@/types/api';
import type { RequestFn } from './utils';
export function createRequestsApi(request: RequestFn) {
@@ -42,6 +42,10 @@ export function createRequestsApi(request: RequestFn) {
getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`),
deleteRequestComment: (requestId: number, commentId: number): Promise => request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }),
approveRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/approve/`, { method: 'POST' }),
+ getRequestVacationTerms: (requestId: number, period: RequestVacationStatsPeriod = 'half_year'): Promise =>
+ request(`/api/v1/requests/${requestId}/vacation-terms/?period=${period}`),
+ saveRequestVacationTerms: (requestId: number, payload: { paid_days: number; record_to_finance: boolean }, period: RequestVacationStatsPeriod = 'half_year'): Promise =>
+ request(`/api/v1/requests/${requestId}/vacation-terms/?period=${period}`, { method: 'PUT', body: JSON.stringify(payload) }),
rejectRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/reject/`, { method: 'POST' }),
cancelRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/cancel/`, { method: 'POST' }),
addRequestComment: (requestId: number, text: string) => request(`/api/v1/requests/${requestId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
diff --git a/frontend/src/lib/payroll-columns.test.js b/frontend/src/lib/payroll-columns.test.js
index 803cf5df..4f1f9e66 100644
--- a/frontend/src/lib/payroll-columns.test.js
+++ b/frontend/src/lib/payroll-columns.test.js
@@ -6,13 +6,37 @@ import {
normalizePayrollColumnOrder,
} from "./payroll-columns.ts";
-test("payroll column order keeps saved columns and appends newly available ones", () => {
+test("payroll column order keeps saved columns and places newly available ones by default neighbors", () => {
assert.deepEqual(
normalizePayrollColumnOrder(
["payable", "actual_points", "removed", "payable"],
["rate_amount", "actual_points", "payable", "status"],
),
- ["payable", "actual_points", "rate_amount", "status"],
+ ["payable", "rate_amount", "actual_points", "status"],
+ );
+});
+
+test("new payroll component stays before the calculation group", () => {
+ assert.deepEqual(
+ normalizePayrollColumnOrder(
+ ["rate_amount", "component:BONUS", "gross_total", "payable", "status"],
+ [
+ "rate_amount",
+ "component:BONUS",
+ "component:CUSTOM_ADVANCE",
+ "gross_total",
+ "payable",
+ "status",
+ ],
+ ),
+ [
+ "rate_amount",
+ "component:BONUS",
+ "component:CUSTOM_ADVANCE",
+ "gross_total",
+ "payable",
+ "status",
+ ],
);
});
diff --git a/frontend/src/lib/payroll-columns.ts b/frontend/src/lib/payroll-columns.ts
index b6f6a084..6836d31f 100644
--- a/frontend/src/lib/payroll-columns.ts
+++ b/frontend/src/lib/payroll-columns.ts
@@ -9,8 +9,18 @@ export function normalizePayrollColumnOrder(
seen.add(columnId);
return true;
});
- for (const columnId of availableIds) {
- if (!seen.has(columnId)) normalized.push(columnId);
+ for (const [availableIndex, columnId] of availableIds.entries()) {
+ if (seen.has(columnId)) continue;
+
+ const nextKnownId = availableIds
+ .slice(availableIndex + 1)
+ .find((candidateId) => seen.has(candidateId));
+ if (nextKnownId) {
+ normalized.splice(normalized.indexOf(nextKnownId), 0, columnId);
+ } else {
+ normalized.push(columnId);
+ }
+ seen.add(columnId);
}
return normalized;
}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index ab411625..da2ad23c 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -1265,6 +1265,40 @@ export interface Request {
decided_at?: string | null;
created_at: string;
updated_at: string;
+ vacation_terms?: RequestVacationTerms | null;
+ vacation_terms_required?: boolean;
+ can_manage_vacation_terms?: boolean;
+}
+
+export interface VacationFinanceInstruction {
+ id: number;
+ status: "pending_amount" | "draft" | "posted" | "voided";
+ paid_days: number;
+ amount: string | null;
+ daily_rate: string | null;
+ period_id: number | null;
+ payroll_input_line_id: number | null;
+}
+
+export interface RequestVacationTerms {
+ paid_days: number;
+ unpaid_days: number;
+ total_days: number;
+ finance_status: "not_decided" | "skipped" | "pending" | "posted";
+ recorded_by: User;
+ recorded_at: string;
+ updated_at: string;
+ finance_instruction: VacationFinanceInstruction | null;
+}
+
+export type RequestVacationStatsPeriod = "half_year" | "year" | "month" | "all";
+
+export interface RequestVacationTermsContext {
+ request_id: number;
+ total_days: number;
+ terms: RequestVacationTerms | null;
+ statistics: RequestEmployeeStatistics;
+ request?: Request;
}
export interface RequestComment {
@@ -1278,7 +1312,7 @@ export interface RequestComment {
export interface RequestEmployeeStatistics {
employee_id: number;
employee_name: string;
- period: "all" | "year" | "month" | "custom";
+ period: "all" | "half_year" | "year" | "month" | "custom";
date_from: string | null;
date_to: string | null;
total_submitted_requests: number;