R47 A1-A8:完成薪资管理模块
这个提交包含在:
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchEmployeePayslips,
|
||||
fetchEmployeeSalaryProfile,
|
||||
fetchSalaryItems,
|
||||
upsertEmployeeSalaryProfile,
|
||||
type EmployeeSalaryProfileSnapshot,
|
||||
type Payslip,
|
||||
} from "../../api/payroll";
|
||||
import { mapApiErrorMessage } from "../../api/errorMessages";
|
||||
import { isApiError } from "../../api/types";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "../common/StatusBadge";
|
||||
|
||||
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 todayDateString() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function moneyText(value: number) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function payslipStatusLabel(status: Payslip["status"]) {
|
||||
return status === "issued" ? "已发放" : "草稿";
|
||||
}
|
||||
|
||||
type EmployeeSalaryPanelProps = {
|
||||
employeeId: string;
|
||||
canManage: boolean;
|
||||
isSelf: boolean;
|
||||
};
|
||||
|
||||
export function EmployeeSalaryPanel({ employeeId, canManage, isSelf }: EmployeeSalaryPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [effectiveDate, setEffectiveDate] = useState(todayDateString());
|
||||
const [amountByItemId, setAmountByItemId] = useState<Record<string, string>>({});
|
||||
const [formInitialized, setFormInitialized] = useState(false);
|
||||
|
||||
const salaryItemsQuery = useQuery({
|
||||
enabled: canManage,
|
||||
queryKey: ["payroll-items"],
|
||||
queryFn: fetchSalaryItems,
|
||||
});
|
||||
const salaryProfileQuery = useQuery({
|
||||
enabled: canManage,
|
||||
queryKey: ["payroll-employee-salary-profile", employeeId],
|
||||
queryFn: () => fetchEmployeeSalaryProfile(employeeId),
|
||||
});
|
||||
const payslipsQuery = useQuery({
|
||||
enabled: canManage || isSelf,
|
||||
queryKey: ["payroll-employee-payslips", employeeId],
|
||||
queryFn: () => fetchEmployeePayslips(employeeId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEffectiveDate(todayDateString());
|
||||
setAmountByItemId({});
|
||||
setFormInitialized(false);
|
||||
}, [employeeId]);
|
||||
|
||||
function applySnapshot(snapshot?: EmployeeSalaryProfileSnapshot | null) {
|
||||
if (!salaryItemsQuery.data?.items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValues = Object.fromEntries(
|
||||
salaryItemsQuery.data.items.map((item) => {
|
||||
const snapshotItem = snapshot?.items.find((entry) => entry.salaryItemId === item.id);
|
||||
return [item.id, snapshotItem ? moneyText(snapshotItem.monthlyAmount) : "0.00"];
|
||||
}),
|
||||
);
|
||||
setAmountByItemId(nextValues);
|
||||
setEffectiveDate(snapshot?.effectiveDate ?? todayDateString());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManage || formInitialized || !salaryItemsQuery.data?.items.length) {
|
||||
return;
|
||||
}
|
||||
applySnapshot(salaryProfileQuery.data?.snapshots[0] ?? null);
|
||||
setFormInitialized(true);
|
||||
}, [canManage, formInitialized, salaryItemsQuery.data, salaryProfileQuery.data]);
|
||||
|
||||
const saveProfileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const items = (salaryItemsQuery.data?.items ?? []).map((item) => ({
|
||||
salaryItemId: item.id,
|
||||
monthlyAmount: Number(amountByItemId[item.id] || "0"),
|
||||
}));
|
||||
|
||||
return upsertEmployeeSalaryProfile(employeeId, {
|
||||
effectiveDate,
|
||||
items,
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["payroll-employee-salary-profile", employeeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["payroll-employee-payslips", employeeId] }),
|
||||
]);
|
||||
applySnapshot(result.snapshots[0] ?? null);
|
||||
toast.success("薪资档案已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存薪资档案失败")),
|
||||
});
|
||||
|
||||
const payslips = payslipsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{canManage ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-900">历史快照</h3>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>生效日期</TableHead>
|
||||
<TableHead>项数</TableHead>
|
||||
<TableHead>预览</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{salaryProfileQuery.data?.snapshots.length ? (
|
||||
salaryProfileQuery.data.snapshots.map((snapshot) => (
|
||||
<TableRow key={snapshot.effectiveDate}>
|
||||
<TableCell>{snapshot.effectiveDate}</TableCell>
|
||||
<TableCell>{snapshot.items.length}</TableCell>
|
||||
<TableCell className="max-w-[240px] text-sm text-zinc-600">
|
||||
{snapshot.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.name} ${moneyText(item.monthlyAmount)}`)
|
||||
.join(" / ")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => applySnapshot(snapshot)}>
|
||||
套用
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-4 text-sm text-zinc-500">
|
||||
{salaryProfileQuery.isLoading ? "加载薪资快照中..." : "暂无薪资快照"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-zinc-200 bg-zinc-50 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-900">保存薪资快照</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">默认带出最近一版快照,可修改为新的生效日期后保存。</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => applySnapshot(salaryProfileQuery.data?.snapshots[0] ?? null)}>
|
||||
带出最新快照
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs text-zinc-500">生效日期</label>
|
||||
<Input type="date" className="mt-2" value={effectiveDate} onChange={(event) => setEffectiveDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{(salaryItemsQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="grid gap-3 rounded-2xl border border-zinc-200 bg-white px-4 py-3 md:grid-cols-[1fr_100px_160px] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.itemType === "earning" ? "收入项" : "扣除项"}</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">排序 {item.sortOrder}</div>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={amountByItemId[item.id] ?? "0.00"}
|
||||
onChange={(event) =>
|
||||
setAmountByItemId((current) => ({
|
||||
...current,
|
||||
[item.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button type="button" onClick={() => saveProfileMutation.mutate()} disabled={saveProfileMutation.isPending}>
|
||||
保存薪资快照
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="card-std p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">{canManage ? "工资条记录" : "已发放工资条"}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{canManage ? "管理员可查看该员工全部工资条;员工本人仅可查看已发放工资条。 " : "仅展示已发放工资条,点击展开查看各项明细。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{payslips.length ? (
|
||||
payslips.map((payslip) => (
|
||||
<details key={payslip.id} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium text-zinc-900">{payslip.payMonth}</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">
|
||||
应发 {moneyText(payslip.grossAmount)} / 应扣 {moneyText(payslip.deductionAmount)} / 实发 {moneyText(payslip.netAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={payslip.status} label={payslipStatusLabel(payslip.status)} />
|
||||
{payslip.issuedAt ? <div className="text-xs text-zinc-500">发放于 {payslip.issuedAt.slice(0, 10)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div className="mt-4 border-t border-zinc-100 pt-4">
|
||||
<div className="space-y-2">
|
||||
{payslip.items.map((item) => (
|
||||
<div key={item.salaryItemId} className="flex items-center justify-between rounded-2xl border border-zinc-100 bg-zinc-50 px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.itemType === "earning" ? "收入项" : "扣除项"}</div>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-zinc-900">{moneyText(item.amount)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">
|
||||
{payslipsQuery.isLoading ? "加载工资条中..." : canManage ? "暂无工资条记录" : "暂无已发放工资条"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户