diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx
index bc01e831..60bfaf9a 100644
--- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx
+++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx
@@ -34,7 +34,7 @@ import { formatPayrollPoints, normalizePayrollPoints } from "@/lib/payroll";
const inputClass = "app-input w-full rounded-lg px-3 py-2.5 text-sm";
const selectClass = "app-select w-full rounded-lg px-3 py-2.5 text-sm";
-function ModalActions({
+export function ModalActions({
busy,
disabled = false,
onClose,
@@ -71,7 +71,7 @@ function ModalActions({
);
}
-function FormError({ message }: { message: string | null }) {
+export function FormError({ message }: { message: string | null }) {
if (!message) return null;
return (
diff --git a/frontend/src/components/finance/admin/PayrollAdminTables.tsx b/frontend/src/components/finance/admin/PayrollAdminTables.tsx
index 32e0ebf1..512086c0 100644
--- a/frontend/src/components/finance/admin/PayrollAdminTables.tsx
+++ b/frontend/src/components/finance/admin/PayrollAdminTables.tsx
@@ -40,17 +40,19 @@ function RecordStatus({ status }: { status: PayrollApprovalStatus }) {
);
}
-function CompactRecordCard({
+export function CompactRecordCard({
title,
subtitle,
summary,
status,
+ statusContent,
children,
}: {
title: string;
subtitle: string;
summary: ReactNode;
status: PayrollApprovalStatus;
+ statusContent?: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
@@ -81,7 +83,7 @@ function CompactRecordCard({
-
+ {statusContent ?? }
{open ? (
@@ -93,7 +95,7 @@ function CompactRecordCard({
);
}
-function CompactField({
+export function CompactField({
label,
children,
}: {
@@ -110,7 +112,7 @@ function CompactField({
);
}
-function ListState({
+export function ListState({
loading,
error,
empty,
@@ -242,7 +244,7 @@ export function PayrollInputHistory({
);
}
-function ActionButton({
+export function ActionButton({
label,
icon,
onClick,
diff --git a/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx b/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx
index aaa869ef..9203a5b5 100644
--- a/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx
+++ b/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx
@@ -1,8 +1,24 @@
"use client";
import { useCallback, useEffect, useState, type FormEvent } from "react";
-import { Loader2, Plus, RefreshCw, Search } from "lucide-react";
+import { Plus, RefreshCw, RotateCcw, Search } from "lucide-react";
+
import { Modal } from "@/components/ui";
+import { CompactFilterMenu } from "@/components/ui/CompactFilterMenu";
+import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
+import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
+import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
+import {
+ FormError,
+ ModalActions,
+ PayrollConfirmModal,
+} from "./PayrollAdminModals";
+import {
+ ActionButton,
+ CompactField,
+ CompactRecordCard,
+ ListState,
+} from "./PayrollAdminTables";
import { apiClient } from "@/lib/api";
import type {
PayrollAdminEmployee,
@@ -12,15 +28,30 @@ import type {
PayrollOffCycleSettlement,
} from "@/lib/api/finance";
import { getPayrollAdminError } from "@/lib/payroll-admin";
-import { formatPayrollDate, formatPayrollMoney } from "@/lib/payroll";
+import {
+ formatPayrollDate,
+ formatPayrollDateTime,
+ formatPayrollMoney,
+} from "@/lib/payroll";
const inputClass = "app-input w-full rounded-lg px-3 py-2.5 text-sm";
+const selectClass = "app-select w-full rounded-lg px-3 py-2.5 text-sm";
function localToday() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
+function PaymentStatus({ payment }: { payment: PayrollOffCyclePayment }) {
+ return (
+
+ {payment.voided_at ? "Отменена" : "Выдано"}
+
+ );
+}
+
function PaymentForm({
employees,
periods,
@@ -48,13 +79,6 @@ function PaymentForm({
useState
(null);
const [settlementLoading, setSettlementLoading] = useState(false);
const [settlementError, setSettlementError] = useState(null);
- const matches = employees
- .filter((item) =>
- item.display_name
- .toLocaleLowerCase("ru")
- .includes(name.toLocaleLowerCase("ru").trim()),
- )
- .slice(0, 8);
const sourcePeriod = periods.find(
(period) => period.id === Number(sourcePeriodId),
);
@@ -126,89 +150,72 @@ function PaymentForm({
onClose={() => {
if (!busy) onClose();
}}
- title="Добавить выплату вне периода"
- size="lg"
+ title="Новая выплата вне периода"
+ size="md"
>
-
);
}
+function PaymentRecords({
+ payments,
+ canManage,
+ onVoid,
+}: {
+ payments: PayrollOffCyclePayment[];
+ canManage: boolean;
+ onVoid: (payment: PayrollOffCyclePayment) => void;
+}) {
+ const action = (payment: PayrollOffCyclePayment) =>
+ canManage && !payment.voided_at ? (
+ }
+ onClick={() => onVoid(payment)}
+ />
+ ) : null;
+ return (
+
+
+ {payments.map((payment) => (
+
+ {formatPayrollMoney(payment.amount, payment.currency)}
+
+ }
+ status={payment.voided_at ? "voided" : "approved"}
+ statusContent={}
+ >
+
+
+ {payment.employee_id ? "Сотрудник из базы" : "Ручная запись"}
+
+ {payment.settlement ? (
+
+ {formatPayrollMoney(
+ payment.settlement.remaining,
+ payment.currency,
+ )}
+
+ ) : null}
+
+
+ {payment.note}
+
+
+
+ {formatPayrollDateTime(payment.created_at)}
+
+
+ {payment.created_by.display_name}
+
+ {payment.voided_at ? (
+
+
+ {payment.void_reason} · {payment.voided_by?.display_name} ·{" "}
+ {formatPayrollDateTime(payment.voided_at)}
+
+
+ ) : null}
+
+ {canManage && !payment.voided_at ? (
+
+ {action(payment)}
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+ {[
+ "Получатель",
+ "Дата выдачи",
+ "Выдано",
+ "За период",
+ "Основание / история",
+ "Статус",
+ "Действия",
+ ].map((label) => (
+ |
+ {label}
+ |
+ ))}
+
+
+
+ {payments.map((payment) => (
+
+ |
+
+ {payment.payee_name}
+
+
+ {payment.employee_id
+ ? "Сотрудник из базы"
+ : "Ручная запись"}
+
+ |
+
+ {formatPayrollDate(payment.paid_on)}
+ |
+
+ {formatPayrollMoney(payment.amount, payment.currency)}
+ |
+
+ {payment.source_period_name || "Без привязки"}
+ {payment.settlement ? (
+
+ Осталось:{" "}
+ {formatPayrollMoney(
+ payment.settlement.remaining,
+ payment.currency,
+ )}
+
+ ) : null}
+ |
+
+
+ {payment.note}
+
+
+ {payment.created_by.display_name} ·{" "}
+ {formatPayrollDateTime(payment.created_at)}
+
+ {payment.voided_at ? (
+
+ Отменено: {payment.void_reason} ·{" "}
+ {payment.voided_by?.display_name} ·{" "}
+ {formatPayrollDateTime(payment.voided_at)}
+
+ ) : null}
+ |
+
+
+ |
+
+ {action(payment)}
+ |
+
+ ))}
+
+
+
+
+ );
+}
+
export function PayrollOffCyclePayments({
employees,
periods,
@@ -365,20 +503,21 @@ export function PayrollOffCyclePayments({
const [dateTo, setDateTo] = useState(period?.date_to || "");
const [search, setSearch] = useState("");
const [includeVoided, setIncludeVoided] = useState(false);
+ const [filtersOpen, setFiltersOpen] = useState(false);
const [page, setPage] = useState(1);
const [data, setData] = useState(null);
- const [loading, setLoading] = useState(false);
+ const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState(null);
const [formOpen, setFormOpen] = useState(false);
const [voiding, setVoiding] = useState(null);
- const [voidReason, setVoidReason] = useState("");
- const [voidBusy, setVoidBusy] = useState(false);
const currency = period?.currency || "RUB";
const reload = useCallback(() => {
setVersion((value) => value + 1);
}, []);
+ const from = compact ? period?.date_from : dateFrom;
+ const to = compact ? period?.date_to : dateTo;
useEffect(() => {
let cancelled = false;
@@ -388,8 +527,8 @@ export function PayrollOffCyclePayments({
setError(null);
apiClient
.getPayrollOffCyclePayments({
- date_from: (compact ? period?.date_from : dateFrom) || undefined,
- date_to: (compact ? period?.date_to : dateTo) || undefined,
+ date_from: from || undefined,
+ date_to: to || undefined,
search: search || undefined,
currency,
include_voided: includeVoided,
@@ -417,26 +556,15 @@ export function PayrollOffCyclePayments({
cancelled = true;
clearTimeout(timer);
};
- }, [
- compact,
- currency,
- dateFrom,
- dateTo,
- includeVoided,
- page,
- period?.date_from,
- period?.date_to,
- search,
- version,
- ]);
+ }, [currency, from, to, includeVoided, page, search, version]);
const saved = (payment: PayrollOffCyclePayment) => {
setFormOpen(false);
+ setPage(1);
+ setSearch("");
setNotice(
`Записана выплата: ${payment.payee_name} · ${formatPayrollMoney(payment.amount, payment.currency)} · ${formatPayrollDate(payment.paid_on)}. Учтена по дате выдачи.`,
);
- setPage(1);
- setSearch("");
if (
!compact &&
((dateFrom && payment.paid_on < dateFrom) ||
@@ -448,24 +576,12 @@ export function PayrollOffCyclePayments({
reload();
onChanged?.();
};
- const cancelPayment = async (event: FormEvent) => {
- event.preventDefault();
- if (!voiding || voidBusy) return;
- setVoidBusy(true);
- setError(null);
- try {
- await apiClient.voidPayrollOffCyclePayment(voiding.id, voidReason.trim());
- setNotice(
- `Запись о выплате ${voiding.payee_name} на ${formatPayrollMoney(voiding.amount, voiding.currency)} отменена и исключена из итогов.`,
- );
- setVoiding(null);
- reload();
- onChanged?.();
- } catch (voidError) {
- setError(getPayrollAdminError(voidError, "Не удалось отменить выплату."));
- } finally {
- setVoidBusy(false);
- }
+ const cancelPayment = async (reason: string) => {
+ if (!voiding) return;
+ await apiClient.voidPayrollOffCyclePayment(voiding.id, reason.trim());
+ setNotice(
+ `Запись о выплате ${voiding.payee_name} на ${formatPayrollMoney(voiding.amount, voiding.currency)} отменена и исключена из итогов.`,
+ );
};
return (
@@ -476,318 +592,191 @@ export function PayrollOffCyclePayments({
: "app-surface rounded-2xl p-4 sm:p-5"
}
>
-
-
-
Выплаты вне периода
-
- Фактическая выдача, включая выплаты бывшим сотрудникам. Без новых
- расчётных листков.
-
- {compact && period ? (
-
- По дате выдачи: {formatPayrollDate(period.date_from)} —{" "}
- {formatPayrollDate(period.date_to)}
-
- ) : null}
-
-
-
- {canManage ? (
-
- ) : null}
+
+
+
+ Выплаты вне периода
+
+ Без рассылки
+
+ Погашение долгов и разовые выдачи, в том числе бывшим сотрудникам.
+
{notice ? (
-
- {notice}
-
- ) : null}
-
- Выдано вне периода:{" "}
- {formatPayrollMoney(data?.total_paid || "0", currency)}
-
- за выбранный диапазон и фильтр
-
-
-
- {!compact ? (
- <>
-
-
-
- >
- ) : null}
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
- {loading && !data ? (
-
- ) : (
-
-
-
-
- {[
- "Дата выдачи",
- "Получатель",
- "За период",
- "Выдано",
- "Основание / история",
- "",
- ].map((label) => (
- |
- {label}
- |
- ))}
-
-
-
- {data?.results.map((payment) => (
-
- |
- {formatPayrollDate(payment.paid_on)}
- |
-
- {payment.payee_name}
-
- {payment.employee_id
- ? "Связан с сотрудником"
- : "Ручная запись"}
-
- |
-
- {payment.source_period_name || "Без привязки"}
- {payment.settlement ? (
-
- Осталось:{" "}
- {formatPayrollMoney(
- payment.settlement.remaining,
- payment.currency,
- )}
-
- ) : null}
- |
-
- {formatPayrollMoney(payment.amount, payment.currency)}
- |
-
-
- {payment.note}
-
-
- Записал: {payment.created_by.display_name} ·{" "}
- {formatPayrollDate(payment.created_at)}
-
- {payment.voided_at ? (
-
- Отменено: {payment.void_reason} ·{" "}
- {payment.voided_by?.display_name} ·{" "}
- {formatPayrollDate(payment.voided_at)}
-
- ) : null}
- |
-
- {canManage && !payment.voided_at ? (
-
- ) : null}
- |
-
- ))}
-
-
-
- |
- Итого по всем найденным действующим выплатам
- |
-
- {formatPayrollMoney(data?.total_paid || "0", currency)}
- |
- |
-
-
-
- {!data?.results.length ? (
-
- Выплат по выбранным условиям пока нет.
-
- ) : null}
-
- )}
- {!loading || data ? (
-
- {data?.results.map((payment) => (
-
-
-
- {payment.payee_name}
-
-
- {formatPayrollMoney(payment.amount, payment.currency)}
-
-
-
- {formatPayrollDate(payment.paid_on)} ·{" "}
- {payment.source_period_name || "Без привязки к периоду"}
-
- {payment.settlement ? (
-
- Осталось выдать:{" "}
- {formatPayrollMoney(
- payment.settlement.remaining,
- payment.currency,
- )}
-
- ) : null}
-
- {payment.note}
-
-
- Записал: {payment.created_by.display_name} ·{" "}
- {formatPayrollDate(payment.created_at)}
-
- {payment.voided_at ? (
-
- Отменено: {payment.void_reason} ·{" "}
- {payment.voided_by?.display_name} ·{" "}
- {formatPayrollDate(payment.voided_at)}
-
- ) : canManage ? (
-
- ) : null}
-
- ))}
- {!data?.results.length ? (
-
- Выплат по выбранным условиям пока нет.
-
- ) : null}
-
- ) : null}
- {data && data.count > data.page_size ? (
-
+ {notice}
+
+ ) : null}
+
+
+
+
+
setFiltersOpen((open) => !open)}
+ activeCount={
+ Number(!compact && Boolean(from || to)) + Number(includeVoided)
+ }
+ ariaLabel="Фильтры выплат"
+ width={280}
+ >
+
+ {!compact ? (
+
+
+
+
+ ) : null}
+
+
Статус
+
{
+ setIncludeVoided(value === "all");
+ setPage(1);
+ }}
+ />
+
+ {!compact ? (
+
+ ) : null}
+
+
+
+
+ {canManage ? (
+
+
setFormOpen(true)}
+ icon={}
+ label="Добавить выплату"
+ />
+
+ ) : null}
+
+
+
+
+ Записей{" "}
+
+ {data?.count || 0}
+
+
+
+ Выдано{" "}
+
+ {formatPayrollMoney(data?.total_paid || "0", currency)}
+
+
+
+ {from || to
+ ? `${from ? formatPayrollDate(from) : "Начало учёта"} — ${to ? formatPayrollDate(to) : "сегодня"}`
+ : "За всё время"}{" "}
+ · по дате выдачи
+
+
+ {loading || error || !data?.results.length ? (
+
+ ) : (
+
+ )}
+ {data && data.count > data.page_size ? (
+
+
-
+
{page} / {Math.ceil(data.count / data.page_size)}