feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化

- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页
- Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度)
- 待办分为「风险提醒」「月度任务」两个顶层 tab
- 归档与工资条生成解耦:归档只锁定批次,工资条单独生成
- 工资条管理新增「从批次汇总生成」按钮
- Dashboard 总览优先从已归档批次 BatchEntry 汇总数据
- 新增工资条生成待办提醒,生成后自动标记完成
- 修复高风险统计只含 CONTRACT/TERMINATION 类型
- 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
freedakgmail
2026-07-23 13:42:28 +08:00
parent 820579e98d
commit 2a09d31ccc
20 changed files with 2300 additions and 235 deletions
+95
View File
@@ -0,0 +1,95 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number // 当前页(1-based
pageSize: number // 每页条数
total: number // 总条数
onPageChange: (page: number) => void
onPageSizeChange?: (size: number) => void
pageSizeOptions?: number[]
}
export default function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
// 生成页码按钮(最多显示 7 个)
const pages: (number | '...')[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1)
if (page > 3) pages.push('...')
const s = Math.max(2, page - 1)
const e = Math.min(totalPages - 1, page + 1)
for (let i = s; i <= e; i++) pages.push(i)
if (page < totalPages - 2) pages.push('...')
pages.push(totalPages)
}
return (
<div className="flex items-center justify-between gap-4 py-2">
{/* 左侧:条数信息 + 每页条数选择 */}
<div className="flex items-center gap-3 text-xs text-gray-500">
<span> {total} </span>
{onPageSizeChange && (
<select
className="border rounded px-1 py-0.5 text-xs text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
))}
</select>
)}
<span> {start}-{end} </span>
</div>
{/* 右侧:页码导航 */}
<div className="flex items-center gap-1">
<button
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-gray-400 text-xs"></span>
) : (
<button
key={p}
className={clsx(
'min-w-[28px] h-7 rounded text-xs font-medium transition-colors',
p === page
? 'bg-primary text-white'
: 'text-gray-600 hover:bg-gray-100',
)}
onClick={() => onPageChange(p)}
>
{p}
</button>
),
)}
<button
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)
}
+12 -9
View File
@@ -6,6 +6,9 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
interface EmployeeOption {
id: string
name: string
@@ -198,28 +201,28 @@ function SeveranceCalculator() {
{result.capped && (
<div className="text-sm text-warning"> 312</div>
)}
<div className="text-sm text-gray-500"><span className="text-gray-900">¥{result.wage.toLocaleString()}/</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">¥{fmt(result.wage)}/</span></div>
<div className="border-t pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
<span className="font-medium">¥{result.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className="font-medium">¥{fmt(result.basePay)}</span>
</div>
{result.isIllegal ? (
<>
<div className="flex items-center justify-between">
<span className="font-medium text-danger">×2</span>
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{result.wage.toLocaleString()} × 2</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{fmt(result.wage)} × 2</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<span className="font-medium">{result.reason}</span>
<span className="text-xl font-bold text-primary">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className="text-xl font-bold text-primary">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{result.wage.toLocaleString()}
{result.noticePay > 0 && <span className="block"> ¥{result.noticePay.toLocaleString()}</span>}
<div className="text-xs text-gray-400">{result.compMonths} × ¥{fmt(result.wage)})
{result.noticePay > 0 && <span className="block"> ¥{fmt(result.noticePay)}</span>}
</div>
</>
)}
@@ -337,9 +340,9 @@ function DoubleSalaryCalculator() {
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString()}</span>
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">{result.months} × ¥{monthlyWage.toLocaleString()}</div>
<div className="text-xs text-gray-400 mt-1">{result.months} × ¥{fmt(monthlyWage)}</div>
</div>
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
+43 -16
View File
@@ -1,21 +1,40 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo } from 'lucide-react'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Signal from '../components/ui/Signal'
import Pagination from '../components/ui/Pagination'
import type { DashboardData } from '../types'
function fmt(n: number) {
return `¥${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; bg: string }> = {
CONTRACT: { icon: FileText, color: 'text-blue-600', bg: 'bg-blue-50' },
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
}
function TodoIcon({ type, level }: { type: string; level: string }) {
const config = TODO_ICON_CONFIG[type] || TODO_ICON_CONFIG.MONTHLY
const Icon = config.icon
return (
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
<Icon className="w-4 h-4" />
</div>
)
}
export default function Dashboard() {
const [todoPage, setTodoPage] = useState(1)
const [todoPageSize, setTodoPageSize] = useState(10)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'todos'>('overview')
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
@@ -34,6 +53,10 @@ export default function Dashboard() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION') || []
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
if (isLoading) {
return <div className="text-center py-8 text-gray-400">...</div>
}
@@ -75,7 +98,8 @@ export default function Dashboard() {
const tabs = [
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
{ key: 'payroll' as const, label: '薪税', icon: Calculator, badge: payroll?.payslipCount ?? 0 },
{ key: 'todos' as const, label: '待办', icon: ListTodo, badge: data.todos.length },
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
return (
@@ -274,27 +298,29 @@ export default function Dashboard() {
</Card>
)}
{/* 待办 Tab */}
{activeTab === 'todos' && (
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-4">
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold"></h2>
<span className="text-sm text-gray-400">{data.todos.length} </span>
<h2 className="font-semibold">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-sm text-gray-400">{filteredTodos.length} </span>
</div>
{data.todos.length === 0 ? (
<EmptyState title="暂无待办" description="所有风险项已处理完毕" />
{filteredTodos.length === 0 ? (
<EmptyState title="暂无待办" description="所有项已处理完毕" />
) : (
<div className="space-y-2">
{data.todos.map((todo) => (
<>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-3 py-3 rounded-md hover:bg-gray-50 transition-colors"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<Signal level={todo.level} />
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-sm text-gray-800">{todo.title}</span>
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
@@ -320,7 +346,8 @@ export default function Dashboard() {
</div>
</div>
))}
</div>
</div>
</>
)}
</Card>
@@ -338,7 +365,7 @@ export default function Dashboard() {
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<Check className="w-4 h-4 text-safe" />
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-sm text-gray-600 line-through">{todo.title}</span>
<span className="text-xs text-gray-400">{todo.description}</span>
+602 -98
View File
@@ -1,17 +1,24 @@
import { useState, useMemo, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, AlertCircle, Info, Save, Check, Upload, Zap, Bell } from 'lucide-react'
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
type Tab = 'overtime' | 'social' | 'payslip'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
type Tab = 'batch' | 'template' | 'overtime' | 'social' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('overtime')
const [tab, setTab] = useState<Tab>('batch')
const tabs: { key: Tab; label: string }[] = [
{ key: 'batch', label: '发薪批次' },
{ key: 'template', label: '薪酬模版' },
{ key: 'overtime', label: '加班费计算' },
{ key: 'social', label: '社保公积金' },
{ key: 'payslip', label: '工资条管理' },
@@ -21,12 +28,12 @@ export default function Money() {
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
<div className="flex gap-1 border-b">
<div className="flex gap-1 border-b overflow-x-auto">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
@@ -35,6 +42,8 @@ export default function Money() {
))}
</div>
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'social' && <SocialInsuranceCalculator />}
{tab === 'payslip' && <PayslipManager />}
@@ -42,6 +51,561 @@ export default function Money() {
)
}
// ========== 发薪批次管理 ==========
function BatchManager() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS'>('REGULAR')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: checkResult } = useQuery<any>({
queryKey: ['batch-check', month],
queryFn: async () => {
const res = await api.get('/payroll2/batches/check', { params: { month } }) as any
return res.data
},
})
const { data: batches, isLoading } = useQuery<any[]>({
queryKey: ['batches', month],
queryFn: async () => {
const res = await api.get('/payroll2/batches', { params: { month } }) as any
return res.data
},
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll2/batches', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
setShowCreateModal(false)
},
})
if (selectedBatchId) {
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
}
return (
<div className="space-y-4">
{/* 重复发薪提醒 */}
{checkResult?.hasArchivedBatch && (
<div className="flex items-center gap-2 p-3 rounded-md bg-amber-50 text-warning text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{checkResult.archivedCount} {checkResult.payslipsPublished ? ',工资条已发布' : ''}
</div>
)}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-40" />
{batches && batches.length > 0 && (
<span className="text-sm text-gray-500">{batches.length} </span>
)}
</div>
<div className="flex gap-2">
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{showCreateModal && (
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4 max-w-md">
<div>
<Label></Label>
<Select value={createType} onChange={(e) => setCreateType(e.target.value as any)}>
<option value="REGULAR"></option>
<option value="TERMINATION"></option>
<option value="BONUS">/</option>
</Select>
</div>
<div className="text-sm text-gray-500">
<p> </p>
<p> </p>
<p> /</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => createMutation.mutate({ month, type: createType })}
disabled={createMutation.isPending}
>
{createMutation.isPending ? '创建中...' : '确认创建'}
</Button>
<Button variant="secondary" onClick={() => setShowCreateModal(false)}></Button>
</div>
</div>
</Card>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !batches || batches.length === 0 ? (
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
) : (
<div className="space-y-3">
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
{batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => (
<Card key={batch.id} className="cursor-pointer hover:shadow-md transition-shadow" >
<div className="flex items-center justify-between" onClick={() => setSelectedBatchId(batch.id)}>
<div className="flex items-center gap-3">
<Layers className="w-5 h-5 text-primary" />
<div>
<div className="font-medium">{batch.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
{batch.employeeCount} · ¥{fmt(batch.totalPay)} · ¥{fmt(batch.totalNetPay)}
{batch.type === 'BONUS' && ' · 单独计税'}
{batch.type === 'TERMINATION' && ' · 离职结算'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{batch.status === 'ARCHIVED' ? (
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe flex items-center gap-1">
<Archive className="w-3 h-3" />
</span>
) : (
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">稿</span>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
)
}
function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) {
const queryClient = useQueryClient()
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
const [editValue, setEditValue] = useState<string>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showAddEmployee, setShowAddEmployee] = useState(false)
const { data: batch, isLoading } = useQuery<any>({
queryKey: ['batch-detail', batchId],
queryFn: async () => {
const res = await api.get(`/payroll2/batches/${batchId}`) as any
return res.data
},
})
const updateEntryMutation = useMutation({
mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) =>
api.put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
},
})
const removeEmployeeMutation = useMutation({
mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['batch-detail'] }),
})
const archiveMutation = useMutation({
mutationFn: () => api.post(`/payroll2/batches/${batchId}/archive`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
alert('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。')
},
})
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!batch) return <div className="text-center py-8 text-gray-400"></div>
const isArchived = batch.status === 'ARCHIVED'
const isBonus = batch.type === 'BONUS'
// 可编辑的输入项字段
const editableFields = isBonus
? ['bonus']
: ['baseSalary', 'overtimePay', 'allowance', 'deduction', 'bonus']
// 点击单元格进入编辑
const startEdit = (employeeId: string, field: string, currentValue: number) => {
if (isArchived || !editableFields.includes(field)) return
setEditCell({ employeeId, field })
setEditValue(currentValue.toFixed(2))
}
// 保存编辑
const saveEdit = () => {
if (!editCell) return
const { employeeId, field } = editCell
const numValue = Number(editValue) || 0
// 找到当前 entry 的其他字段值一起提交
const entry = batch.entries.find((e: any) => e.employeeId === employeeId)
if (!entry) { setEditCell(null); return }
const data: any = {}
editableFields.forEach(f => {
data[f] = f === field ? numValue : entry[f]
})
updateEntryMutation.mutate({ employeeId, data })
setEditCell(null)
}
// 失焦保存
const handleBlur = () => saveEdit()
// Enter 保存,Esc 取消
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); saveEdit() }
if (e.key === 'Escape') setEditCell(null)
}
// 渲染可编辑单元格
const renderCell = (entry: any, field: string, className: string = '') => {
const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === field
const canEdit = !isArchived && editableFields.includes(field)
const value = entry[field] || 0
const displayValue = field === 'deduction' && value > 0 ? '-' + fmt(value) : fmt(value)
if (isEditing) {
return (
<td className="py-1 px-1 text-right" key={field}>
<input
type="text"
inputMode="decimal"
autoFocus
className="w-24 text-right border-b-2 border-primary bg-transparent px-1 py-0.5 text-xs focus:outline-none [appearance:none]"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
</td>
)
}
return (
<td
key={field}
className={`py-2 px-2 text-right ${className} ${canEdit ? 'cursor-text' : ''}`}
onClick={() => canEdit && startEdit(entry.employeeId, field, value)}
>
{canEdit ? (
<span className={`border-b border-dashed border-gray-300 hover:border-primary ${field === 'deduction' && value > 0 ? 'text-danger' : ''}`}>
{displayValue}
</span>
) : field === 'deduction' && value > 0 ? (
<span className="text-danger">{displayValue}</span>
) : (
displayValue
)}
</td>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-400 hover:text-gray-600 text-sm"> </button>
<h2 className="font-semibold">{batch.name}</h2>
{isArchived ? (
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
) : (
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">稿</span>
)}
</div>
{!isArchived && (
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowAddEmployee(!showAddEmployee)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<Button
size="sm"
onClick={() => {
if (confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
archiveMutation.mutate()
}
}}
disabled={archiveMutation.isPending}
>
<Archive className="w-4 h-4 mr-1" />
{archiveMutation.isPending ? '归档中...' : '归档'}
</Button>
</div>
)}
{isArchived && (
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Download className="w-4 h-4 mr-1" />
</Button>
</a>
)}
</div>
{/* 批次汇总 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="flex items-center gap-3">
<div>
<div className="text-xl font-bold">{batch.employeeCount}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-3">
<div>
<div className="text-xl font-bold text-primary">¥{fmt(batch.totalPay)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-3">
<div>
<div className="text-xl font-bold text-danger">¥{fmt(batch.totalTax)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-3">
<div>
<div className="text-xl font-bold text-safe">¥{fmt(batch.totalNetPay)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
</div>
{/* 添加人员 */}
{showAddEmployee && !isArchived && (
<AddEmployeeToBatch batchId={batchId} onClose={() => setShowAddEmployee(false)} />
)}
{/* 提示 */}
{!isArchived && (
<div className="text-xs text-gray-400 flex items-center gap-1">
<Info className="w-3.5 h-3.5" />
线
</div>
)}
{/* 人员表格 */}
<Card>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 px-2"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
{isBonus && <th className="py-2 px-2 text-right"></th>}
{!isBonus && <th className="py-2 px-2 text-right"></th>}
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right text-gray-400"></th>
<th className="py-2 px-2 text-right text-gray-400"></th>
<th className="py-2 px-2 text-right text-gray-400"></th>
<th className="py-2 px-2 text-right text-gray-400"></th>
<th className="py-2 px-2 text-right text-gray-400"></th>
<th className="py-2 px-2"></th>
{!isArchived && <th className="py-2 px-2"></th>}
</tr>
</thead>
<tbody>
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => (
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-25">
<td className="py-2 px-2">
<div className="font-medium">{entry.employee.name}</div>
<div className="text-xs text-gray-400">{entry.employee.department}</div>
{entry.employee.status === 'RESIGNED' && (
<span className="text-xs text-danger"></span>
)}
</td>
{renderCell(entry, 'baseSalary')}
{renderCell(entry, 'overtimePay')}
{renderCell(entry, 'allowance')}
{renderCell(entry, 'bonus')}
{renderCell(entry, 'deduction')}
{/* 计算项 - 灰显 */}
<td className="py-2 px-2 text-right font-medium text-gray-600">{fmt(entry.totalPay)}</td>
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.socialEmp)}</td>
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.housingEmp)}</td>
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.tax)}</td>
<td className="py-2 px-2 text-right font-bold text-safe">{fmt(entry.netPay)}</td>
<td className="py-2 px-2">
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
<AlertTriangle className="w-4 h-4" />
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
{!isArchived && (
<td className="py-2 px-2">
<button
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
className="text-gray-400 hover:text-danger p-1"
title="移除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)
}
function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () => void }) {
const queryClient = useQueryClient()
const [selected, setSelected] = useState<string[]>([])
const { data: employees } = useQuery<any>({
queryKey: ['employees-for-batch'],
queryFn: async () => {
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
return res.data
},
})
const addMutation = useMutation({
mutationFn: (employeeIds: string[]) => api.post(`/payroll2/batches/${batchId}/employees`, { employeeIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
onClose()
},
})
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium"></h3>
<button onClick={onClose} className="text-gray-400"></button>
</div>
<div className="max-h-60 overflow-y-auto space-y-1">
{employees?.items?.map((emp: any) => (
<label key={emp.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={selected.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) setSelected([...selected, emp.id])
else setSelected(selected.filter(id => id !== emp.id))
}}
/>
<span className="text-sm">{emp.name} - {emp.department}</span>
</label>
))}
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={() => addMutation.mutate(selected)} disabled={selected.length === 0 || addMutation.isPending}>
{addMutation.isPending ? '添加中...' : `添加 ${selected.length}`}
</Button>
<Button size="sm" variant="secondary" onClick={onClose}></Button>
</div>
</Card>
)
}
// ========== 薪酬模版管理 ==========
function TemplateManager() {
const queryClient = useQueryClient()
const { data: items, isLoading } = useQuery<any[]>({
queryKey: ['payslip-template'],
queryFn: async () => {
const res = await api.get('/payroll2/template') as any
return res.data
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/payroll2/template/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip-template'] }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payroll2/template/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip-template'] }),
})
return (
<div className="space-y-4">
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-medium"></h2>
<span className="text-sm text-gray-400"></span>
</div>
{isLoading ? (
<div className="text-center py-4 text-gray-400">...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{items?.map((item: any) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2 px-2 text-gray-400">{item.order}</td>
<td className="py-2 px-2 font-medium">{item.name}</td>
<td className="py-2 px-2 text-gray-500 font-mono text-xs">{item.code}</td>
<td className="py-2 px-2">
<span className={`px-2 py-0.5 rounded text-xs ${item.type === 'INPUT' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
{item.type === 'INPUT' ? '输入项' : '计算项'}
</span>
</td>
<td className="py-2 px-2 text-gray-500 text-xs font-mono">{item.formula || '—'}</td>
<td className="py-2 px-2">
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-400'}`}>
{item.isEditable ? '可编辑' : '不可编辑'}
</span>
</td>
<td className="py-2 px-2">
{!item.isDefault && (
<button
onClick={() => deleteMutation.mutate(item.id)}
className="text-xs text-gray-400 hover:text-danger"
>
</button>
)}
{item.isDefault && <span className="text-xs text-gray-300"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-400 mt-3">
</p>
</Card>
</div>
)
}
function OvertimeCalculator() {
const queryClient = useQueryClient()
const [monthlyWage, setMonthlyWage] = useState(8000)
@@ -166,7 +730,7 @@ function OvertimeCalculator() {
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<div className="space-y-3">
<div className="text-sm text-gray-500"><span className="text-gray-900 font-medium">¥{result.hourlyWage.toFixed(2)}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900 font-medium">¥{fmt(result.hourlyWage)}</span></div>
<div className="space-y-2">
<ResultRow label={`工作日 ${weekdayHours}h × 1.5`} value={result.weekdayPay} />
<ResultRow label={`休息日 ${weekendHours}h × 2.0`} value={result.weekendPay} />
@@ -175,7 +739,7 @@ function OvertimeCalculator() {
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-primary">¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className="text-xl font-bold text-primary">¥{fmt(result.total)}</span>
</div>
</div>
{result.totalHours > 36 && (
@@ -235,7 +799,7 @@ function ResultRow({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{label}</span>
<span className="font-medium">¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className="font-medium">¥{fmt(value)}</span>
</div>
)
}
@@ -243,21 +807,8 @@ function ResultRow({ label, value }: { label: string; value: number }) {
function PayslipManager() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({
employeeId: '',
baseSalary: 8000,
allowance: 0,
deduction: 0,
})
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
queryKey: ['employees-for-payslip'],
queryFn: async () => {
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
return res.data
},
})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
@@ -267,24 +818,18 @@ function PayslipManager() {
},
})
const generateMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/payslip/generate', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
setShowCreate(false)
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
})
const batchGenerateMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/payslip/batch-generate', data),
onSuccess: () => {
const generateFromBatchMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll2/payslips/generate', data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
alert('批量生成完成')
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
const n = res?.data?.generated || 0
alert(`已从归档批次汇总生成 ${n} 条工资条并发布。`)
},
})
@@ -305,68 +850,27 @@ function PayslipManager() {
)}
</div>
<div className="flex gap-2">
<Button onClick={() => setShowCreate(!showCreate)}></Button>
<Button
variant="secondary"
onClick={() => batchGenerateMutation.mutate({ month })}
disabled={batchGenerateMutation.isPending}
onClick={() => {
if (confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
generateFromBatchMutation.mutate({ month })
}
}}
disabled={generateFromBatchMutation.isPending}
>
<Zap className="w-4 h-4 mr-1" />
{batchGenerateMutation.isPending ? '生成中...' : '一键全员生成'}
<Layers className="w-4 h-4 mr-1" />
{generateFromBatchMutation.isPending ? '生成中...' : '从批次汇总生成'}
</Button>
</div>
</div>
{showCreate && (
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label></Label>
<Select value={createForm.employeeId} onChange={(e) => setCreateForm({ ...createForm, employeeId: e.target.value })}>
<option value=""></option>
{employees?.items.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.baseSalary} onChange={(e) => setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.allowance} onChange={(e) => setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.deduction} onChange={(e) => setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
onClick={() => generateMutation.mutate({
employeeId: createForm.employeeId,
month,
baseSalary: createForm.baseSalary,
allowance: createForm.allowance,
deduction: createForm.deduction,
})}
disabled={!createForm.employeeId || generateMutation.isPending}
>
{generateMutation.isPending ? '生成中...' : '确认生成(自动关联加班费)'}
</Button>
<Button variant="secondary" onClick={() => setShowCreate(false)}></Button>
</div>
</Card>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !payslips || payslips.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -383,15 +887,15 @@ function PayslipManager() {
</tr>
</thead>
<tbody>
{payslips.map((p: any) => (
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.employee?.name}</td>
<td className="py-2 text-gray-500">{p.employee?.department}</td>
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}</td>
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{fmt(p.baseSalary)}</td>
<td className="py-2 text-right">¥{fmt(p.overtimePay)}</td>
<td className="py-2 text-right">¥{fmt(p.allowance)}</td>
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}</td>
<td className="py-2 text-right font-bold">¥{fmt(p.totalPay)}</td>
<td className="py-2 text-center">
{p.confirmedAt ? (
<span className="inline-flex items-center gap-1 text-safe text-xs">
@@ -549,7 +1053,7 @@ function SocialInsuranceCalculator() {
{result ? (
<div className="space-y-3">
<div className="text-sm text-gray-500">
<span className="text-gray-900 font-medium">¥{result.actualBase.toLocaleString()}</span>
<span className="text-gray-900 font-medium">¥{fmt(result.actualBase)}</span>
{result.capped && <span className="text-warning ml-2"></span>}
{result.floored && <span className="text-warning ml-2"></span>}
</div>
@@ -570,16 +1074,16 @@ function SocialInsuranceCalculator() {
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
<td className="py-1.5 text-right">¥{item.orgAmount.toFixed(2)}</td>
<td className="py-1.5 text-right">¥{item.empAmount.toFixed(2)}</td>
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-bold">
<td className="py-2" colSpan={3}></td>
<td className="py-2 text-right text-danger">¥{result.totalOrg.toFixed(2)}</td>
<td className="py-2 text-right text-warning">¥{result.totalEmp.toFixed(2)}</td>
<td className="py-2 text-right text-danger">¥{fmt(result.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(result.totalEmp)}</td>
</tr>
</tfoot>
</table>
@@ -587,10 +1091,10 @@ function SocialInsuranceCalculator() {
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-primary">¥{result.total.toFixed(2)}</span>
<span className="text-xl font-bold text-primary">¥{fmt(result.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{result.totalOrg.toFixed(2)} + ¥{result.totalEmp.toFixed(2)}
¥{fmt(result.totalOrg)} + ¥{fmt(result.totalEmp)}
</div>
</div>
</div>
+93 -13
View File
@@ -7,6 +7,10 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Signal from '../components/ui/Signal'
import Pagination from '../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence'
@@ -15,6 +19,8 @@ export default function Roster() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: employees, isLoading } = useQuery<any[]>({
queryKey: ['roster'],
@@ -36,6 +42,7 @@ export default function Roster() {
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
) || []
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
@@ -64,6 +71,7 @@ export default function Roster() {
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -82,7 +90,7 @@ export default function Roster() {
</tr>
</thead>
<tbody>
{filtered.map((e: any) => (
{paged.map((e: any) => (
<tr
key={e.id}
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
@@ -96,7 +104,7 @@ export default function Roster() {
</span>
</td>
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
<td className="py-2 px-3 text-right">¥{e.monthlySalary.toLocaleString()}</td>
<td className="py-2 px-3 text-right">¥{fmt(e.monthlySalary)}</td>
<td className="py-2 px-3">
<Signal level={e.latestContract?.riskLevel || 'safe'} label={e.latestContract ? (e.latestContract.contractType === 'FIXED' ? '固定期限' : e.latestContract.contractType === 'UNFIXED' ? '无固定期限' : '未签') : '无合同'} />
</td>
@@ -204,13 +212,30 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
}
function BasicInfo({ profile }: { profile: any }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
socialInsBase: profile.socialInsBase ?? '',
housingFundBase: profile.housingFundBase ?? '',
specialDeduction: profile.specialDeduction ?? 0,
})
const updateMutation = useMutation({
mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
})
const fields = [
{ label: '姓名', value: profile.name },
{ label: '部门', value: profile.department },
{ label: '性别', value: profile.gender || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '月工资', value: `¥${profile.monthlySalary.toLocaleString()}` },
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
{ label: '住址', value: profile.address || '未填写' },
@@ -225,6 +250,23 @@ function BasicInfo({ profile }: { profile: any }) {
]
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-medium"></h2>
{!editing ? (
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}></Button>
) : (
<div className="flex gap-2">
<Button size="sm" onClick={() => updateMutation.mutate({
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
specialDeduction: Number(form.specialDeduction) || 0,
})} disabled={updateMutation.isPending}>
{updateMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}></Button>
</div>
)}
</div>
<div className="grid md:grid-cols-2 gap-4">
{fields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-2">
@@ -233,6 +275,44 @@ function BasicInfo({ profile }: { profile: any }) {
</div>
))}
</div>
{/* 薪税信息 */}
<div className="mt-4 pt-4 border-t">
<h3 className="text-sm font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="grid md:grid-cols-3 gap-4">
<div className="flex justify-between border-b pb-2">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
</div>
) : (
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
</div>
<div>
<Label>/</Label>
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
</div>
</div>
)}
<p className="text-xs text-gray-400 mt-2">/7portal端填报0</p>
</div>
<div className="mt-4 flex gap-4">
{special.map((s) => (
<span key={s.label} className={`px-3 py-1 rounded text-sm ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
@@ -487,11 +567,11 @@ function PayslipInfo({ payslips }: { payslips: any[] }) {
{payslips.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.month}</td>
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{fmt(p.baseSalary)}</td>
<td className="py-2 text-right">¥{fmt(p.overtimePay)}</td>
<td className="py-2 text-right">¥{fmt(p.allowance)}</td>
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{fmt(p.deduction)}</td>
<td className="py-2 text-right font-bold">¥{fmt(p.totalPay)}</td>
<td className="py-2 text-center">
{p.confirmedAt ? <span className="text-safe text-xs"></span> : <span className="text-warning text-xs"></span>}
</td>
@@ -524,7 +604,7 @@ function OvertimeInfo({ records }: { records: any[] }) {
<td className="py-2 text-right">{o.weekdayHours}</td>
<td className="py-2 text-right">{o.weekendHours}</td>
<td className="py-2 text-right">{o.holidayHours}</td>
<td className="py-2 text-right font-medium">¥{o.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right font-medium">¥{fmt(o.totalPay)}</td>
</tr>
))}
</tbody>
@@ -581,7 +661,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
<strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong>
</p>
<p>{legalBasisMap[printRecord.reason] || ''}</p>
<p><strong>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
<p><strong>¥{fmt(printRecord.compensation)}</strong></p>
{printRecord.remark && <p>{printRecord.remark}</p>}
<p></p>
<div className="text-right mt-6 space-y-1">
@@ -597,10 +677,10 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
<div className="text-sm space-y-1">
<div className="flex justify-between"><span></span><span>{profile.name}{profile.department}</span></div>
<div className="flex justify-between"><span></span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span></span><span>¥{profile.monthlySalary.toLocaleString()}/</span></div>
<div className="flex justify-between"><span></span><span>¥{fmt(profile.monthlySalary)}/</span></div>
<div className="flex justify-between"><span></span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span></span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{fmt(printRecord.compensation)}</span></div>
</div>
</div>
@@ -678,7 +758,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
<div className="grid md:grid-cols-2 gap-4">
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{t.terminationDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{reasonMap[t.reason] || t.reason}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">¥{t.compensation.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(t.compensation)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{t.riskLevel === 'SAFE' ? '安全' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}</span></div>
{t.remark && <div className="md:col-span-2"><span className="text-gray-500"></span><span>{t.remark}</span></div>}
<div className="md:col-span-2 flex justify-end">
+11
View File
@@ -76,6 +76,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
name: orgData?.data?.name || '',
contactName: orgData?.data?.contactName || '',
contactPhone: orgData?.data?.contactPhone || '',
payrollFrequency: orgData?.data?.payrollFrequency || 1,
})
return (
@@ -94,6 +95,16 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
<Label></Label>
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<div>
<Label></Label>
<Select value={String(form.payrollFrequency)} onChange={(e) => setForm({ ...form, payrollFrequency: Number(e.target.value) })}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</Select>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
+24 -21
View File
@@ -7,6 +7,9 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Signal from '../components/ui/Signal'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const REASONS = [
{ value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' },
{ value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' },
@@ -304,7 +307,7 @@ export default function Termination() {
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee.name}{selectedEmployee.department}</div>
<div>{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
<div>¥{selectedEmployee.monthlySalary.toLocaleString()}</div>
<div>¥{fmt(selectedEmployee.monthlySalary)}</div>
{selectedEmployee.latestContract ? (
<div>{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
) : (
@@ -432,7 +435,7 @@ export default function Termination() {
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee?.name}{selectedEmployee?.department}</div>
<div>{costResult.years}{costResult.remainingMonths}</div>
<div>¥{costResult.wage.toLocaleString()}/</div>
<div>¥{fmt(costResult.wage)}/</div>
{costResult.capped && (
<div className="text-warning"> 312</div>
)}
@@ -450,27 +453,27 @@ export default function Termination() {
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
</div>
<div className="text-sm text-gray-500">{costResult.cappedMonths}</div>
<div className="text-sm text-gray-500">¥{costResult.cappedWage.toLocaleString()}/</div>
<div className="text-sm text-gray-500">¥{fmt(costResult.cappedWage)}/</div>
{costResult.isIllegal && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500"></span>
<span>¥{costResult.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span>¥{fmt(costResult.basePay)}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="font-medium">{costResult.isIllegal ? '赔偿金(×2' : '补偿金'}</span>
<span className={`text-lg font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}
¥{fmt(costResult.severancePay)}
</span>
</div>
{costResult.noticePay > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500"></span>
<span>¥{costResult.noticePay.toLocaleString()}</span>
<span>¥{fmt(costResult.noticePay)}</span>
</div>
)}
{costResult.noticePay > 0 && (
<div className="text-xs text-gray-400"> ¥{costResult.noticePay.toLocaleString()}</div>
<div className="text-xs text-gray-400"> ¥{fmt(costResult.noticePay)}</div>
)}
{costResult.isIllegal && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
@@ -493,9 +496,9 @@ export default function Termination() {
<div className="text-sm text-gray-500">{costResult.doubleMonths}</div>
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-warning">¥{costResult.doublePay.toLocaleString()}</span>
<span className="text-lg font-bold text-warning">¥{fmt(costResult.doublePay)}</span>
</div>
<div className="text-xs text-gray-400">{costResult.doubleMonths} × ¥{costResult.wage.toLocaleString()}</div>
<div className="text-xs text-gray-400">{costResult.doubleMonths} × ¥{fmt(costResult.wage)}</div>
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-800 text-xs">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>1211</span>
@@ -507,7 +510,7 @@ export default function Termination() {
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-danger">¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className="text-xl font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
</div>
</div>
@@ -568,9 +571,9 @@ export default function Termination() {
</p>
{costResult && !costResult.noComp && (
<p>
<strong>{costResult.cappedMonths}</strong> <strong>¥{costResult.cappedWage.toLocaleString()}/</strong>
<strong>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong>
{costResult.noticePay > 0 && `(含代通知金 ¥${costResult.noticePay.toLocaleString()}`}
<strong>{costResult.cappedMonths}</strong> <strong>¥{fmt(costResult.cappedWage)}/</strong>
<strong>¥{fmt(costResult.severancePay)}</strong>
{costResult.noticePay > 0 && `(含代通知金 ¥${fmt(costResult.noticePay)}`}
</p>
)}
@@ -579,11 +582,11 @@ export default function Termination() {
)}
{costResult && !costResult.hasContract && costResult.doubleMonths > 0 && (
<p>
{costResult.doubleMonths} <strong>¥{costResult.doublePay.toLocaleString()}</strong>
{costResult.doubleMonths} <strong>¥{fmt(costResult.doublePay)}</strong>
</p>
)}
{costResult && (
<p><strong>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
<p><strong>¥{fmt(costResult.grandTotal)}</strong></p>
)}
<p></p>
<div className="text-right mt-6 space-y-1">
@@ -599,20 +602,20 @@ export default function Termination() {
<h3 className="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" /></h3>
<div className="text-sm space-y-1">
<div className="flex justify-between"><span></span><span>{costResult.years}{costResult.remainingMonths}</span></div>
<div className="flex justify-between"><span></span><span>¥{costResult.wage.toLocaleString()}/</span></div>
<div className="flex justify-between"><span></span><span>¥{fmt(costResult.wage)}/</span></div>
{costResult.capped && <div className="text-warning"> 312</div>}
{!costResult.noComp && (
<>
<div className="flex justify-between"><span></span><span>{costResult.cappedMonths}</span></div>
<div className="flex justify-between"><span></span><span>¥{costResult.cappedWage.toLocaleString()}/</span></div>
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2' : '经济补偿金'}</span><span>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
{costResult.noticePay > 0 && <div className="flex justify-between"><span></span><span>¥{costResult.noticePay.toLocaleString()}</span></div>}
<div className="flex justify-between"><span></span><span>¥{fmt(costResult.cappedWage)}/</span></div>
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2' : '经济补偿金'}</span><span>¥{fmt(costResult.severancePay)}</span></div>
{costResult.noticePay > 0 && <div className="flex justify-between"><span></span><span>¥{fmt(costResult.noticePay)}</span></div>}
</>
)}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="flex justify-between text-warning"><span>{costResult.doubleMonths}</span><span>¥{costResult.doublePay.toLocaleString()}</span></div>
<div className="flex justify-between text-warning"><span>{costResult.doubleMonths}</span><span>¥{fmt(costResult.doublePay)}</span></div>
)}
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{fmt(costResult.grandTotal)}</span></div>
</div>
</div>
)}
@@ -5,6 +5,9 @@ import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function ContractConfirm() {
const [params] = useSearchParams()
const token = params.get('token') || ''
@@ -83,7 +86,7 @@ export default function ContractConfirm() {
<Row label="合同开始" value={new Date(data.contract.startDate).toISOString().slice(0, 10)} />
{data.contract.endDate && <Row label="合同结束" value={new Date(data.contract.endDate).toISOString().slice(0, 10)} />}
{data.contract.probationMonths > 0 && <Row label="试用期" value={`${data.contract.probationMonths}个月`} />}
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${Number(data.contract.probationSalary).toLocaleString()}`} />}
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(data.contract.probationSalary))}`} />}
</div>
)}
+4 -1
View File
@@ -5,6 +5,9 @@ import api from '../../lib/api'
import Card from '../../components/ui/Card'
import EmptyState from '../../components/ui/EmptyState'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
@@ -65,7 +68,7 @@ export default function MyContract() {
{contract.endDate && <Row label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />}
{contract.contractYears > 0 && <Row label="合同期限" value={`${contract.contractYears}`} />}
{contract.probationMonths > 0 && <Row label="试用期" value={`${contract.probationMonths}个月`} />}
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${Number(contract.probationSalary).toLocaleString()}`} />}
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />}
</div>
{/* 签署确认记录 */}
+8 -5
View File
@@ -7,6 +7,9 @@ import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
@@ -65,32 +68,32 @@ export default function Payslip() {
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{Number(data.baseSalary).toLocaleString()}</span>
<span className="font-medium">¥{fmt(Number(data.baseSalary))}</span>
</div>
{data.overtimePay > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{Number(data.overtimePay).toLocaleString()}</span>
<span className="font-medium">¥{fmt(Number(data.overtimePay))}</span>
</div>
</div>
)}
{data.allowance > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{Number(data.allowance).toLocaleString()}</span>
<span className="font-medium">¥{fmt(Number(data.allowance))}</span>
</div>
)}
{data.deduction > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-danger">-¥{Number(data.deduction).toLocaleString()}</span>
<span className="font-medium text-danger">-¥{fmt(Number(data.deduction))}</span>
</div>
)}
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-primary">¥{Number(data.totalPay).toLocaleString()}</span>
<span className="text-xl font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
</div>
</div>
+2
View File
@@ -85,6 +85,7 @@ export interface DashboardData {
}
todos: {
id: string
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY'
level: 'high' | 'medium' | 'low'
title: string
description: string
@@ -92,6 +93,7 @@ export interface DashboardData {
}[]
resolvedTodos: {
id: string
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY'
level: 'high' | 'medium' | 'low'
title: string
description: string