e01bad4815
- 优化-1: 社保公积金独立配置+版本化缴费记录+多城市支持+迁移脚本 - 优化-2: AI流式输出/RAG集成/风险角标/审计日志/二维码/批量续签/忘记密码/语音输入/PDF导出/速率限制/套餐人数上限 - 优化-3: 批次重命名/费用实时预览/模拟版本管理/续签合规预检/社保重置/搜索分页/批量解聘/到期预警/税率试算 - 优化-4: 会话历史/待办批量/结果关联档案/风险下钻/预测上下文/附件校验/Tab级联/薪税导出 - 优化-5: 表单回填/用户编辑禁用/导入预览/选择性导出/通知测试/错误日志导出/脱敏导出/gzip压缩 - 优化-6: 工资条确认通知HR/AI上下文增强/电子签名/用量限制修复/入职文件上传/RAG管理/工资趋势/用量事务/验证码加固/审查结构化/链接撤回/超时机制/确认重发/案例转待办
187 lines
7.5 KiB
TypeScript
187 lines
7.5 KiB
TypeScript
import { useState } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { Link } from 'react-router-dom'
|
||
import { DollarSign, Check, TrendingUp, Download } 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'
|
||
|
||
// 金额格式化:保留两位小数 + 千分位
|
||
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')
|
||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||
return config
|
||
})
|
||
|
||
export default function Payslip() {
|
||
const queryClient = useQueryClient()
|
||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||
const [showHistory, setShowHistory] = useState(false)
|
||
|
||
const { data, isLoading } = useQuery<any>({
|
||
queryKey: ['payslip', month],
|
||
queryFn: async () => {
|
||
const res = await portalApi.get('/payslip', { params: { month } }) as any
|
||
return res.data?.data ?? null
|
||
},
|
||
})
|
||
|
||
const { data: history } = useQuery<any[]>({
|
||
queryKey: ['payslip-history'],
|
||
queryFn: async () => {
|
||
const res = await portalApi.get('/payslip/history') as any
|
||
return res.data?.data ?? []
|
||
},
|
||
})
|
||
|
||
const confirmMutation = useMutation({
|
||
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
|
||
})
|
||
|
||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||
|
||
const handleExport = () => {
|
||
if (!history || history.length === 0) return
|
||
const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态']
|
||
const rows = history.map((p: any) => [
|
||
p.month,
|
||
p.baseSalary,
|
||
p.overtimePay,
|
||
p.allowance,
|
||
p.deduction,
|
||
p.totalPay,
|
||
p.confirmedAt ? '已确认' : '未确认',
|
||
])
|
||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `工资条_${employee.name || '员工'}_${new Date().toISOString().slice(0, 10)}.csv`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.month))
|
||
const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1)
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||
<div className="max-w-md mx-auto">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-2">
|
||
<DollarSign className="w-6 h-6 text-primary" />
|
||
<h1 className="text-sm font-semibold">我的工资条</h1>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||
<Link to="/portal/contract" className="text-sm text-primary">我的合同</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<input
|
||
type="month"
|
||
value={month}
|
||
onChange={(e) => setMonth(e.target.value)}
|
||
className="px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||
/>
|
||
<button
|
||
onClick={() => setShowHistory(!showHistory)}
|
||
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium"
|
||
>
|
||
<TrendingUp className="w-4 h-4" />趋势
|
||
</button>
|
||
<button
|
||
onClick={handleExport}
|
||
disabled={!history || history.length === 0}
|
||
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50"
|
||
>
|
||
<Download className="w-4 h-4" />导出
|
||
</button>
|
||
</div>
|
||
|
||
{showHistory && sortedHistory.length > 0 && (
|
||
<Card className="mb-4">
|
||
<h3 className="text-xs font-medium mb-3">近 {sortedHistory.length} 个月工资趋势</h3>
|
||
<div className="space-y-2">
|
||
{sortedHistory.map((p: any) => (
|
||
<div key={p.id} className="flex items-center gap-2">
|
||
<span className="text-xs text-gray-500 w-16 flex-shrink-0">{p.month}</span>
|
||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative overflow-hidden">
|
||
<div
|
||
className="bg-primary h-full rounded-full transition-all"
|
||
style={{ width: `${(Number(p.totalPay) / maxPay) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
{isLoading ? (
|
||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||
) : !data ? (
|
||
<EmptyState title="暂无工资条" description={`该月份(${month})暂无工资记录`} />
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-gray-500">基本工资</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">¥{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">¥{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">-¥{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-base font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{data.confirmedAt ? (
|
||
<div className="flex items-center gap-2 text-sm text-safe">
|
||
<Check className="w-4 h-4" /> 已确认({new Date(data.confirmedAt).toLocaleString()})
|
||
</div>
|
||
) : (
|
||
<Button
|
||
className="w-full"
|
||
onClick={() => confirmMutation.mutate(data.id)}
|
||
disabled={confirmMutation.isPending}
|
||
>
|
||
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|