R47 A1-A8:完成薪资管理模块
这个提交包含在:
@@ -250,6 +250,20 @@ export type EmployeeStatsResponse = {
|
||||
validHolderCount: number;
|
||||
expiringSoonCount: number;
|
||||
};
|
||||
salarySummary: {
|
||||
currentMonth: {
|
||||
month: string;
|
||||
totalNetAmount: number;
|
||||
averageNetAmount: number;
|
||||
issuedCount: number;
|
||||
} | null;
|
||||
previousMonth: {
|
||||
month: string;
|
||||
totalNetAmount: number;
|
||||
averageNetAmount: number;
|
||||
issuedCount: number;
|
||||
} | null;
|
||||
} | null;
|
||||
performanceSummary: {
|
||||
cycle: {
|
||||
id: string;
|
||||
|
||||
@@ -83,4 +83,15 @@ export async function exportEmployees(params: {
|
||||
return apiRequestBlob(`/api/exports/employees?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function exportPayslips(params: {
|
||||
format: "csv" | "xlsx";
|
||||
month: string;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
qs.set("month", params.month);
|
||||
|
||||
return apiRequestBlob(`/api/exports/payslips?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export type { ApiBlobResponse };
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type SalaryItemType = "earning" | "deduction";
|
||||
export type PayslipStatus = "draft" | "issued";
|
||||
|
||||
export type SalaryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
itemType: SalaryItemType;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeSalaryProfileItem = {
|
||||
salaryItemId: string;
|
||||
name: string;
|
||||
itemType: SalaryItemType;
|
||||
sortOrder: number;
|
||||
monthlyAmount: number;
|
||||
};
|
||||
|
||||
export type EmployeeSalaryProfileSnapshot = {
|
||||
effectiveDate: string;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
items: EmployeeSalaryProfileItem[];
|
||||
};
|
||||
|
||||
export type PayslipItem = {
|
||||
salaryItemId: string;
|
||||
name: string;
|
||||
itemType: SalaryItemType;
|
||||
sortOrder: number;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type Payslip = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
payMonth: string | null;
|
||||
status: PayslipStatus;
|
||||
grossAmount: number;
|
||||
deductionAmount: number;
|
||||
netAmount: number;
|
||||
issuedAt: string | null;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
employee: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
title: string;
|
||||
department: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
items: PayslipItem[];
|
||||
};
|
||||
|
||||
export type PayrollMonthSummary = {
|
||||
month: string;
|
||||
totalNetAmount: number;
|
||||
averageNetAmount: number;
|
||||
issuedCount: number;
|
||||
};
|
||||
|
||||
export async function fetchSalaryItems() {
|
||||
return apiRequestData<{ items: SalaryItem[] }>("/api/payroll/items");
|
||||
}
|
||||
|
||||
export async function createSalaryItem(payload: {
|
||||
name: string;
|
||||
itemType: SalaryItemType;
|
||||
sortOrder: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<SalaryItem>("/api/payroll/items", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchSalaryItem(
|
||||
itemId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
itemType: SalaryItemType;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<SalaryItem>(`/api/payroll/items/${itemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEmployeeSalaryProfile(employeeId: string) {
|
||||
return apiRequestData<{ employeeId: string; snapshots: EmployeeSalaryProfileSnapshot[] }>(
|
||||
`/api/payroll/employees/${employeeId}/profile`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertEmployeeSalaryProfile(
|
||||
employeeId: string,
|
||||
payload: {
|
||||
effectiveDate: string;
|
||||
items: Array<{
|
||||
salaryItemId: string;
|
||||
monthlyAmount: number;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return apiRequestData<{ employeeId: string; snapshots: EmployeeSalaryProfileSnapshot[] }>(
|
||||
`/api/payroll/employees/${employeeId}/profile`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchEmployeePayslips(employeeId: string) {
|
||||
return apiRequestData<{ employeeId: string; items: Payslip[] }>(`/api/payroll/employees/${employeeId}/payslips`);
|
||||
}
|
||||
|
||||
export async function fetchPayslipsByMonth(month: string) {
|
||||
return apiRequestData<{
|
||||
month: string;
|
||||
salaryItems: SalaryItem[];
|
||||
items: Payslip[];
|
||||
summary: {
|
||||
total: number;
|
||||
draft: number;
|
||||
issued: number;
|
||||
};
|
||||
}>(`/api/payroll/payslips?month=${encodeURIComponent(month)}`);
|
||||
}
|
||||
|
||||
export async function generatePayslips(month: string) {
|
||||
return apiRequestData<{
|
||||
month: string;
|
||||
summary: {
|
||||
total: number;
|
||||
generated: number;
|
||||
};
|
||||
}>("/api/payroll/payslips/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ month }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchPayslip(
|
||||
payslipId: string,
|
||||
payload: {
|
||||
items: Array<{
|
||||
salaryItemId: string;
|
||||
amount: number;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return apiRequestData<Payslip>(`/api/payroll/payslips/${payslipId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function issuePayslips(month: string) {
|
||||
return apiRequestData<{
|
||||
month: string;
|
||||
summary: {
|
||||
total: number;
|
||||
issued: number;
|
||||
};
|
||||
}>("/api/payroll/payslips/issue", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ month }),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { hasPermission } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
|
||||
import { EmployeeSalaryPanel } from "../components/people/EmployeeSalaryPanel";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -47,7 +48,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
|
||||
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance";
|
||||
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance" | "salary";
|
||||
|
||||
type ContractFormState = {
|
||||
contractNo: string;
|
||||
@@ -122,7 +123,7 @@ function buildEmptyCertificateForm(): CertificateFormState {
|
||||
}
|
||||
|
||||
function parseProfileTab(value: string | null): ProfileTab {
|
||||
if (value === "contracts" || value === "trainingCertificates" || value === "performance") {
|
||||
if (value === "contracts" || value === "trainingCertificates" || value === "performance" || value === "salary") {
|
||||
return value;
|
||||
}
|
||||
return "profile";
|
||||
@@ -482,6 +483,7 @@ export function EmployeeProfilePage() {
|
||||
const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data);
|
||||
const showPerformanceTab =
|
||||
canManageEmployeeProfile || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
|
||||
const showSalaryTab = canManageEmployeeProfile || canEditSelfProfile;
|
||||
|
||||
const performanceContent =
|
||||
performanceQuery.isLoading ? (
|
||||
@@ -498,6 +500,7 @@ export function EmployeeProfilePage() {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManageEmployeeProfile} isSelf={canEditSelfProfile} />;
|
||||
|
||||
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
@@ -686,6 +689,7 @@ export function EmployeeProfilePage() {
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
|
||||
{showPerformanceTab ? <TabsTrigger value="performance">绩效</TabsTrigger> : null}
|
||||
{showSalaryTab ? <TabsTrigger value="salary">薪资</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
@@ -1420,6 +1424,12 @@ export function EmployeeProfilePage() {
|
||||
{performanceContent}
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{showSalaryTab ? (
|
||||
<TabsContent value="salary" className="space-y-6">
|
||||
{salaryContent}
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
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<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;
|
||||
@@ -292,6 +292,11 @@ export function PeoplePage() {
|
||||
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManageUsers ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/payroll">薪资管理</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageUsers ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/performance">绩效考核</Link>
|
||||
|
||||
@@ -16,6 +16,10 @@ function employmentStatusLabel(status: EmployeeEmploymentStatus) {
|
||||
return EMPLOYMENT_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
function moneyText(value: number | null | undefined) {
|
||||
return value == null ? "-" : value.toFixed(2);
|
||||
}
|
||||
|
||||
export function PeopleStatsPage() {
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["employee-stats"],
|
||||
@@ -31,6 +35,7 @@ export function PeopleStatsPage() {
|
||||
}
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const salarySummary = stats.salarySummary;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -83,6 +88,38 @@ export function PeopleStatsPage() {
|
||||
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
|
||||
</div>
|
||||
</section>
|
||||
{salarySummary ? (
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">当月薪资总支出</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.currentMonth?.totalNetAmount)}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{salarySummary.currentMonth ? `${salarySummary.currentMonth.month} · 已发放 ${salarySummary.currentMonth.issuedCount} 人` : "当月暂无已发放工资条"}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{salarySummary ? (
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">当月人均薪资</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.currentMonth?.averageNetAmount)}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">仅统计 issued 工资条实发金额</div>
|
||||
</section>
|
||||
) : null}
|
||||
{salarySummary ? (
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">上月薪资总支出</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.totalNetAmount)}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{salarySummary.previousMonth ? `${salarySummary.previousMonth.month} · 已发放 ${salarySummary.previousMonth.issuedCount} 人` : "上月暂无已发放工资条"}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{salarySummary ? (
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">上月人均薪资</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.averageNetAmount)}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">非 system.users 不展示任何薪资汇总</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">当前 / 最近周期平均分</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
NotificationChannelsPage,
|
||||
NotificationsPage,
|
||||
OrganizationPage,
|
||||
PayrollPage,
|
||||
PerformancePage,
|
||||
PaymentBatchesPage,
|
||||
PeoplePage,
|
||||
@@ -305,6 +306,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="payroll"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users"]}>
|
||||
{withRouteFallback(<PayrollPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="performance"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PayrollPage as default } from "../../pages/PayrollPage";
|
||||
@@ -23,6 +23,7 @@ export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
||||
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
||||
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
|
||||
export const PerformancePage = lazy(() => import("./PerformancePage"));
|
||||
export const PayrollPage = lazy(() => import("./PayrollPage"));
|
||||
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
|
||||
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
||||
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户