文件
hyb-oa/client/src/pages/PayrollPage.tsx
T

555 行
22 KiB
TypeScript

import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
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 { 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) {
return resolveUserErrorMessage(error, 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<string, string>) {
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<string | null>(null);
const [salaryItemForm, setSalaryItemForm] = useState<SalaryItemFormState>(buildEmptySalaryItemForm());
const [editingPayslipId, setEditingPayslipId] = useState<string | null>(null);
const [payslipAmounts, setPayslipAmounts] = useState<Record<string, string>>({});
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 (
<div className="space-y-6">
<PageHeader
title="薪资管理"
subtitle="维护薪资模板、按月生成工资条,并对草稿工资条做逐人调整和发放。"
actions={
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary" type="button">
<Link to="/people">返回人员管理</Link>
</Button>
<Button type="button" onClick={openCreateSalaryItemDialog}>
新建薪资项
</Button>
</div>
}
/>
<section className="card-std p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-zinc-900">薪资模板</h2>
<p className="mt-1 text-sm text-zinc-500">工资条生成会基于当前启用的薪资项模板铺开明细,停用仅影响后续生成。</p>
</div>
<div className="text-sm text-zinc-500">{salaryItems.length} 个薪资项</div>
</div>
<div className="mt-6 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>排序</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryItems.length ? (
salaryItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-zinc-900">{item.name}</TableCell>
<TableCell>{salaryItemTypeLabel(item.itemType)}</TableCell>
<TableCell>{item.sortOrder}</TableCell>
<TableCell>
<StatusBadge status={item.enabled ? "active" : "archived"} label={item.enabled ? "启用中" : "已停用"} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => openEditSalaryItemDialog(item)}>
编辑
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={toggleSalaryItemMutation.isPending}
onClick={() => toggleSalaryItemMutation.mutate({ itemId: item.id, enabled: !item.enabled })}
>
{item.enabled ? "停用" : "启用"}
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-4 text-sm text-zinc-500">
{salaryItemsQuery.isLoading ? "加载薪资项中..." : "暂无薪资项"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<section className="card-std p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-lg font-semibold text-zinc-900">月度工资条</h2>
<p className="mt-1 text-sm text-zinc-500">按月份批量生成草稿,逐人调整后统一发放;已发放工资条不可修改。</p>
</div>
<div className="grid gap-3 md:grid-cols-[180px_repeat(4,minmax(0,1fr))]">
<Input type="month" value={month} onChange={(event) => setMonth(event.target.value)} />
<Button type="button" onClick={() => generatePayslipsMutation.mutate()} disabled={!month || generatePayslipsMutation.isPending}>
{generatePayslipsMutation.isPending ? "生成中..." : "生成工资条"}
</Button>
<Button type="button" variant="secondary" onClick={() => issuePayslipsMutation.mutate()} disabled={!month || issuePayslipsMutation.isPending}>
{issuePayslipsMutation.isPending ? "发放中..." : "批量发放"}
</Button>
<Button type="button" variant="secondary" onClick={() => exportMutation.mutate("csv")} disabled={!month || exportMutation.isPending}>
导出 CSV
</Button>
<Button type="button" variant="secondary" onClick={() => exportMutation.mutate("xlsx")} disabled={!month || exportMutation.isPending}>
导出 Excel
</Button>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm text-zinc-500">当月工资条</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{monthSummary.total}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm text-zinc-500">草稿数</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{monthSummary.draft}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm text-zinc-500">已发放数</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{monthSummary.issued}</div>
</div>
</div>
<div className="mt-6 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>员工</TableHead>
<TableHead>部门</TableHead>
<TableHead>状态</TableHead>
<TableHead>明细预览</TableHead>
<TableHead>应发 / 应扣 / 实发</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthPayslips.length ? (
monthPayslips.map((payslip) => (
<TableRow key={payslip.id}>
<TableCell>
<div className="font-medium text-zinc-900">{payslip.employee?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">{payslip.employee?.employeeNo ?? "-"}</div>
</TableCell>
<TableCell>{payslip.employee?.department?.name ?? "-"}</TableCell>
<TableCell>
<StatusBadge status={payslip.status} label={payslipStatusLabel(payslip.status)} />
</TableCell>
<TableCell className="max-w-[360px] text-sm text-zinc-600">
{payslip.items.length
? payslip.items.map((item) => `${item.name} ${moneyText(item.amount)}`).join(" / ")
: "-"}
</TableCell>
<TableCell className="text-sm text-zinc-700">
{moneyText(payslip.grossAmount)} / {moneyText(payslip.deductionAmount)} / {moneyText(payslip.netAmount)}
</TableCell>
<TableCell>
{payslip.status === "draft" ? (
<Button type="button" size="sm" variant="secondary" onClick={() => openPayslipEditor(payslip)}>
调整明细
</Button>
) : (
<div className="text-xs text-zinc-500">已发放,冻结</div>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-4 text-sm text-zinc-500">
{monthPayslipsQuery.isLoading ? "加载工资条中..." : "该月份暂无工资条,先生成后再调整或发放。"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<Dialog open={itemDialogOpen} onOpenChange={(open) => (!open ? resetSalaryItemDialog() : setItemDialogOpen(true))}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingItemId ? "编辑薪资项" : "新建薪资项"}</DialogTitle>
<DialogDescription>定义收入项或扣除项模板,供员工薪资快照和工资条生成复用。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">名称</label>
<Input value={salaryItemForm.name} onChange={(event) => setSalaryItemForm((current) => ({ ...current, name: event.target.value }))} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">类型</label>
<select
className="select-std w-full"
value={salaryItemForm.itemType}
onChange={(event) =>
setSalaryItemForm((current) => ({
...current,
itemType: event.target.value as SalaryItemType,
}))
}
>
{SALARY_ITEM_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">排序</label>
<Input
type="number"
min="0"
max="999"
value={salaryItemForm.sortOrder}
onChange={(event) => setSalaryItemForm((current) => ({ ...current, sortOrder: event.target.value }))}
/>
</div>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
<input
checked={salaryItemForm.enabled}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
type="checkbox"
onChange={(event) => setSalaryItemForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
保存后启用该薪资项
</label>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={resetSalaryItemDialog}>
取消
</Button>
<Button
type="button"
onClick={() => salaryItemMutation.mutate()}
disabled={!salaryItemForm.name.trim() || !salaryItemForm.sortOrder || salaryItemMutation.isPending}
>
{salaryItemMutation.isPending ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingPayslip)} onOpenChange={(open) => (!open ? closePayslipEditor() : undefined)}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>调整工资条</DialogTitle>
<DialogDescription>
{editingPayslip?.employee?.name ?? "-"} · {editingPayslip?.employee?.employeeNo ?? "-"} · {month}
</DialogDescription>
</DialogHeader>
{editingPayslip ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm text-zinc-500">应发预览</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{moneyText(draftTotals?.grossAmount ?? 0)}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm text-zinc-500">应扣预览</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{moneyText(draftTotals?.deductionAmount ?? 0)}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm text-zinc-500">实发预览</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{moneyText(draftTotals?.netAmount ?? 0)}</div>
</div>
</div>
<div className="space-y-3">
{editingPayslip.items.map((item) => (
<div key={item.salaryItemId} className="grid gap-3 rounded-2xl border border-zinc-200 bg-white px-4 py-3 md:grid-cols-[1fr_120px_160px] md:items-center">
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">{salaryItemTypeLabel(item.itemType)}</div>
</div>
<div className="text-sm text-zinc-500">当前 {moneyText(item.amount)}</div>
<Input
inputMode="decimal"
value={payslipAmounts[item.salaryItemId] ?? moneyText(item.amount)}
onChange={(event) =>
setPayslipAmounts((current) => ({
...current,
[item.salaryItemId]: event.target.value,
}))
}
/>
</div>
))}
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="secondary" onClick={closePayslipEditor}>
取消
</Button>
<Button type="button" onClick={() => patchPayslipMutation.mutate()} disabled={!editingPayslip || patchPayslipMutation.isPending}>
{patchPayslipMutation.isPending ? "保存中..." : "保存调整"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default PayrollPage;