import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createSalaryItem, fetchPayslipsByMonth, fetchSalaryItems, generatePayslips, issuePayslips, patchPayslip, patchSalaryItem, type Payslip, type SalaryItem, type SalaryItemType, } from "../api/payroll"; import { exportPayslips } from "../api/exports"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { PageHeader } from "../components/common/PageHeader"; import { StatusBadge } from "../components/common/StatusBadge"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { downloadBlob } from "../lib/download"; import { toast } from "sonner"; type SalaryItemFormState = { name: string; itemType: SalaryItemType; sortOrder: string; enabled: boolean; }; const SALARY_ITEM_TYPE_OPTIONS: Array<{ value: SalaryItemType; label: string }> = [ { value: "earning", label: "收入项" }, { value: "deduction", label: "扣除项" }, ]; function buildEmptySalaryItemForm(): SalaryItemFormState { return { name: "", itemType: "earning", sortOrder: "10", enabled: true, }; } function currentMonthString() { return new Date().toISOString().slice(0, 7); } function errorMessage(error: unknown, fallback: string) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } return error instanceof Error ? error.message : fallback; } function moneyText(value: number) { return value.toFixed(2); } function salaryItemTypeLabel(type: SalaryItemType) { return SALARY_ITEM_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type; } function payslipStatusLabel(status: Payslip["status"]) { return status === "issued" ? "已发放" : "草稿"; } function calculateDraftTotals(items: Payslip["items"], amountByItemId: Record) { let gross = 0; let deduction = 0; for (const item of items) { const value = Number(amountByItemId[item.salaryItemId] ?? item.amount ?? 0); if (!Number.isFinite(value) || value < 0) { continue; } if (item.itemType === "deduction") { deduction += value; } else { gross += value; } } return { grossAmount: gross, deductionAmount: deduction, netAmount: gross - deduction, }; } export function PayrollPage() { const queryClient = useQueryClient(); const [month, setMonth] = useState(currentMonthString()); const [itemDialogOpen, setItemDialogOpen] = useState(false); const [editingItemId, setEditingItemId] = useState(null); const [salaryItemForm, setSalaryItemForm] = useState(buildEmptySalaryItemForm()); const [editingPayslipId, setEditingPayslipId] = useState(null); const [payslipAmounts, setPayslipAmounts] = useState>({}); const salaryItemsQuery = useQuery({ queryKey: ["payroll-items"], queryFn: fetchSalaryItems, }); const monthPayslipsQuery = useQuery({ queryKey: ["payroll-payslips", month], queryFn: () => fetchPayslipsByMonth(month), }); const salaryItems = salaryItemsQuery.data?.items ?? []; const monthPayslips = monthPayslipsQuery.data?.items ?? []; const editingPayslip = monthPayslips.find((item) => item.id === editingPayslipId) ?? null; const draftTotals = editingPayslip ? calculateDraftTotals(editingPayslip.items, payslipAmounts) : null; useEffect(() => { if (editingPayslipId && !editingPayslip) { setEditingPayslipId(null); setPayslipAmounts({}); } }, [editingPayslip, editingPayslipId]); function resetSalaryItemDialog() { setEditingItemId(null); setSalaryItemForm(buildEmptySalaryItemForm()); setItemDialogOpen(false); } function openCreateSalaryItemDialog() { setEditingItemId(null); setSalaryItemForm(buildEmptySalaryItemForm()); setItemDialogOpen(true); } function openEditSalaryItemDialog(item: SalaryItem) { setEditingItemId(item.id); setSalaryItemForm({ name: item.name, itemType: item.itemType, sortOrder: String(item.sortOrder), enabled: item.enabled, }); setItemDialogOpen(true); } function openPayslipEditor(payslip: Payslip) { setEditingPayslipId(payslip.id); setPayslipAmounts( Object.fromEntries(payslip.items.map((item) => [item.salaryItemId, moneyText(item.amount)])), ); } function closePayslipEditor() { setEditingPayslipId(null); setPayslipAmounts({}); } async function invalidatePayrollMonth() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["payroll-items"] }), queryClient.invalidateQueries({ queryKey: ["payroll-payslips", month] }), queryClient.invalidateQueries({ queryKey: ["employee-stats"] }), ]); } const salaryItemMutation = useMutation({ mutationFn: async () => { const payload = { name: salaryItemForm.name.trim(), itemType: salaryItemForm.itemType, sortOrder: Number(salaryItemForm.sortOrder), enabled: salaryItemForm.enabled, }; if (editingItemId) { return patchSalaryItem(editingItemId, payload); } return createSalaryItem(payload); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["payroll-items"] }); resetSalaryItemDialog(); toast.success(editingItemId ? "薪资项已更新" : "薪资项已创建"); }, onError: (error) => toast.error(errorMessage(error, editingItemId ? "更新薪资项失败" : "创建薪资项失败")), }); const toggleSalaryItemMutation = useMutation({ mutationFn: ({ itemId, enabled }: { itemId: string; enabled: boolean }) => patchSalaryItem(itemId, { enabled }), onSuccess: async (_result, variables) => { await queryClient.invalidateQueries({ queryKey: ["payroll-items"] }); toast.success(variables.enabled ? "薪资项已启用" : "薪资项已停用"); }, onError: (error) => toast.error(errorMessage(error, "更新薪资项状态失败")), }); const generatePayslipsMutation = useMutation({ mutationFn: () => generatePayslips(month), onSuccess: async (result) => { await invalidatePayrollMonth(); toast.success(`已生成 ${result.summary.generated} 张工资条`); }, onError: (error) => toast.error(errorMessage(error, "生成工资条失败")), }); const issuePayslipsMutation = useMutation({ mutationFn: () => issuePayslips(month), onSuccess: async (result) => { await invalidatePayrollMonth(); toast.success(`已发放 ${result.summary.issued} 张工资条`); }, onError: (error) => toast.error(errorMessage(error, "发放工资条失败")), }); const patchPayslipMutation = useMutation({ mutationFn: async () => { if (!editingPayslip) { throw new Error("未选择工资条"); } return patchPayslip(editingPayslip.id, { items: editingPayslip.items.map((item) => ({ salaryItemId: item.salaryItemId, amount: Number(payslipAmounts[item.salaryItemId] ?? "0"), })), }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["payroll-payslips", month] }); closePayslipEditor(); toast.success("工资条已更新"); }, onError: (error) => toast.error(errorMessage(error, "更新工资条失败")), }); const exportMutation = useMutation({ mutationFn: (format: "csv" | "xlsx") => exportPayslips({ month, format }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `payslips-${month}.${format}`); toast.success("工资条导出完成"); }, onError: (error) => toast.error(errorMessage(error, "导出工资条失败")), }); const monthSummary = monthPayslipsQuery.data?.summary ?? { total: 0, draft: 0, issued: 0 }; return (
} />

薪资模板

工资条生成会基于当前启用的薪资项模板铺开明细,停用仅影响后续生成。

{salaryItems.length} 个薪资项
名称 类型 排序 状态 操作 {salaryItems.length ? ( salaryItems.map((item) => ( {item.name} {salaryItemTypeLabel(item.itemType)} {item.sortOrder}
)) ) : ( {salaryItemsQuery.isLoading ? "加载薪资项中..." : "暂无薪资项"} )}

月度工资条

按月份批量生成草稿,逐人调整后统一发放;已发放工资条不可修改。

setMonth(event.target.value)} />
当月工资条
{monthSummary.total}
草稿数
{monthSummary.draft}
已发放数
{monthSummary.issued}
员工 部门 状态 明细预览 应发 / 应扣 / 实发 操作 {monthPayslips.length ? ( monthPayslips.map((payslip) => (
{payslip.employee?.name ?? "-"}
{payslip.employee?.employeeNo ?? "-"}
{payslip.employee?.department?.name ?? "-"} {payslip.items.length ? payslip.items.map((item) => `${item.name} ${moneyText(item.amount)}`).join(" / ") : "-"} {moneyText(payslip.grossAmount)} / {moneyText(payslip.deductionAmount)} / {moneyText(payslip.netAmount)} {payslip.status === "draft" ? ( ) : (
已发放,冻结
)}
)) ) : ( {monthPayslipsQuery.isLoading ? "加载工资条中..." : "该月份暂无工资条,先生成后再调整或发放。"} )}
(!open ? resetSalaryItemDialog() : setItemDialogOpen(true))}> {editingItemId ? "编辑薪资项" : "新建薪资项"} 定义收入项或扣除项模板,供员工薪资快照和工资条生成复用。
setSalaryItemForm((current) => ({ ...current, name: event.target.value }))} />
setSalaryItemForm((current) => ({ ...current, sortOrder: event.target.value }))} />
(!open ? closePayslipEditor() : undefined)}> 调整工资条 {editingPayslip?.employee?.name ?? "-"} · {editingPayslip?.employee?.employeeNo ?? "-"} · {month} {editingPayslip ? (
应发预览
{moneyText(draftTotals?.grossAmount ?? 0)}
应扣预览
{moneyText(draftTotals?.deductionAmount ?? 0)}
实发预览
{moneyText(draftTotals?.netAmount ?? 0)}
{editingPayslip.items.map((item) => (
{item.name}
{salaryItemTypeLabel(item.itemType)}
当前 {moneyText(item.amount)}
setPayslipAmounts((current) => ({ ...current, [item.salaryItemId]: event.target.value, })) } />
))}
) : null}
); } export default PayrollPage;