diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index 874e64f1..a921be7d 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -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; diff --git a/client/src/api/exports.ts b/client/src/api/exports.ts index 5de0f845..8d7910de 100644 --- a/client/src/api/exports.ts +++ b/client/src/api/exports.ts @@ -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 }; diff --git a/client/src/api/payroll.ts b/client/src/api/payroll.ts new file mode 100644 index 00000000..91cad3b2 --- /dev/null +++ b/client/src/api/payroll.ts @@ -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("/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(`/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(`/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 }), + }); +} diff --git a/client/src/components/people/EmployeeSalaryPanel.tsx b/client/src/components/people/EmployeeSalaryPanel.tsx new file mode 100644 index 00000000..30441e4b --- /dev/null +++ b/client/src/components/people/EmployeeSalaryPanel.tsx @@ -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>({}); + 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 ( +
+ {canManage ? ( + <> +
+
+
+

薪资档案

+

按生效日期保存整套薪资快照,历史记录保留用于调薪追溯。

+
+
+ +
+
+

历史快照

+
+ + + + 生效日期 + 项数 + 预览 + 操作 + + + + {salaryProfileQuery.data?.snapshots.length ? ( + salaryProfileQuery.data.snapshots.map((snapshot) => ( + + {snapshot.effectiveDate} + {snapshot.items.length} + + {snapshot.items + .slice(0, 3) + .map((item) => `${item.name} ${moneyText(item.monthlyAmount)}`) + .join(" / ")} + + + + + + )) + ) : ( + + + {salaryProfileQuery.isLoading ? "加载薪资快照中..." : "暂无薪资快照"} + + + )} + +
+
+
+ +
+
+
+

保存薪资快照

+

默认带出最近一版快照,可修改为新的生效日期后保存。

+
+ +
+ +
+ + setEffectiveDate(event.target.value)} /> +
+ +
+ {(salaryItemsQuery.data?.items ?? []).map((item) => ( +
+
+
{item.name}
+
{item.itemType === "earning" ? "收入项" : "扣除项"}
+
+
排序 {item.sortOrder}
+ + setAmountByItemId((current) => ({ + ...current, + [item.id]: event.target.value, + })) + } + /> +
+ ))} +
+ +
+ +
+
+
+
+ + ) : null} + +
+
+

{canManage ? "工资条记录" : "已发放工资条"}

+

+ {canManage ? "管理员可查看该员工全部工资条;员工本人仅可查看已发放工资条。 " : "仅展示已发放工资条,点击展开查看各项明细。"} +

+
+ +
+ {payslips.length ? ( + payslips.map((payslip) => ( +
+ +
+
+
{payslip.payMonth}
+
+ 应发 {moneyText(payslip.grossAmount)} / 应扣 {moneyText(payslip.deductionAmount)} / 实发 {moneyText(payslip.netAmount)} +
+
+
+ + {payslip.issuedAt ?
发放于 {payslip.issuedAt.slice(0, 10)}
: null} +
+
+
+ +
+
+ {payslip.items.map((item) => ( +
+
+
{item.name}
+
{item.itemType === "earning" ? "收入项" : "扣除项"}
+
+
{moneyText(item.amount)}
+
+ ))} +
+
+
+ )) + ) : ( +
+ {payslipsQuery.isLoading ? "加载工资条中..." : canManage ? "暂无工资条记录" : "暂无已发放工资条"} +
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index 8e59ce79..5da7af6c 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -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 = ; if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) { return
加载员工档案中...
; @@ -686,6 +689,7 @@ export function EmployeeProfilePage() { {canManageEmployeeProfile ? 合同 : null} {canManageEmployeeProfile ? 培训与证书 : null} {showPerformanceTab ? 绩效 : null} + {showSalaryTab ? 薪资 : null} @@ -1420,6 +1424,12 @@ export function EmployeeProfilePage() { {performanceContent} ) : null} + + {showSalaryTab ? ( + + {salaryContent} + + ) : null} ); diff --git a/client/src/pages/PayrollPage.tsx b/client/src/pages/PayrollPage.tsx new file mode 100644 index 00000000..e3b11cdd --- /dev/null +++ b/client/src/pages/PayrollPage.tsx @@ -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) { + 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; diff --git a/client/src/pages/PeoplePage.tsx b/client/src/pages/PeoplePage.tsx index 94906b4f..fe798106 100644 --- a/client/src/pages/PeoplePage.tsx +++ b/client/src/pages/PeoplePage.tsx @@ -292,6 +292,11 @@ export function PeoplePage() { subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。" actions={
+ {canManageUsers ? ( + + ) : null} {canManageUsers ? (