Files
TurboHR/frontend/src/pages/portal/Payslip.tsx
T

215 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Check, TrendingUp, Download, Wallet, ChevronLeft, ChevronRight } 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 })
/** 员工端 API 实例(自动携带 portalToken */
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 handleMonthChange = (delta: number) => {
const d = new Date(month + '-01')
d.setMonth(d.getMonth() + delta)
setMonth(d.toISOString().slice(0, 7))
}
/** 导出 CSV */
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) => b.month.localeCompare(a.month))
const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1)
/** 工资明细行 */
const SalaryRow = ({ label, value, danger }: { label: string; value: number; danger?: boolean }) => {
if (!value || value === 0) return null
return (
<div className="flex justify-between items-center py-2">
<span className="text-sm text-gray-500">{label}</span>
<span className={`text-sm font-medium ${danger ? 'text-red-500' : 'text-gray-800'}`}>
{danger ? '-' : ''}¥{fmt(Math.abs(Number(value)))}
</span>
</div>
)
}
return (
<div className="space-y-4">
{/* 页面标题 */}
<div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-bold text-gray-900 flex-shrink-0"></h1>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => setShowHistory(!showHistory)}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors ${
showHistory ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-600'
}`}
>
<TrendingUp className="w-3.5 h-3.5" />
</button>
<button
onClick={handleExport}
disabled={!history || history.length === 0}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-gray-100 text-xs font-medium text-gray-600 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 月份选择器 */}
<div className="flex items-center justify-between bg-white rounded-xl px-3 py-3 shadow-sm">
<button onClick={() => handleMonthChange(-1)} className="p-2 -ml-1 rounded-md hover:bg-gray-100 active:bg-gray-200">
<ChevronLeft className="w-5 h-5 text-gray-400" />
</button>
<span className="text-base font-semibold text-gray-900">{month.replace('-', '年')}</span>
<button onClick={() => handleMonthChange(1)} className="p-2 -mr-1 rounded-md hover:bg-gray-100 active:bg-gray-200">
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* 趋势图 */}
{showHistory && sortedHistory.length > 0 && (
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 mb-3"> {sortedHistory.length} </h3>
<div className="space-y-2.5">
{sortedHistory.map((p: any) => (
<div key={p.id} className="flex items-center gap-1.5 min-w-0">
<span className="text-xs text-gray-400 w-12 flex-shrink-0">{p.month.slice(5)}</span>
<div className="flex-1 bg-gray-100 rounded-full h-6 relative overflow-hidden min-w-0">
<div
className="bg-gradient-to-r from-primary to-primary/70 h-full rounded-full transition-all flex items-center justify-end pr-2"
style={{ width: `${Math.max((Number(p.totalPay) / maxPay) * 100, 8)}%` }}
>
{Number(p.totalPay) / maxPay > 0.4 && (
<span className="text-xs text-white font-medium whitespace-nowrap">¥{fmt(Number(p.totalPay))}</span>
)}
</div>
</div>
{Number(p.totalPay) / maxPay <= 0.4 && (
<span className="text-xs font-medium text-gray-600 flex-shrink-0 text-right">¥{fmt(Number(p.totalPay))}</span>
)}
</div>
))}
</div>
</Card>
)}
{/* 工资条卡片 */}
{isLoading ? (
<Card className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-100 rounded-lg w-1/3" />
<div className="h-4 bg-gray-100 rounded w-full" />
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-10 bg-gray-100 rounded-lg w-full" />
</div>
</Card>
) : !data ? (
<Card className="p-6">
<EmptyState title="暂无工资条" description={`${month.replace('-', '年')}月暂无工资记录`} />
</Card>
) : (
<>
{/* 应发合计大卡片 */}
<Card className="p-5 bg-gradient-to-br from-primary to-primary/80 text-white">
<div className="flex items-center gap-2 mb-1">
<Wallet className="w-4 h-4 opacity-80 flex-shrink-0" />
<span className="text-xs opacity-80"></span>
</div>
<div className="text-2xl sm:text-3xl font-bold tracking-tight break-all">¥{fmt(Number(data.totalPay))}</div>
<div className="text-xs opacity-70 mt-1">{month.replace('-', '年')}</div>
</Card>
{/* 工资明细 */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2"></h3>
<div className="divide-y divide-gray-50">
<SalaryRow label="基本工资" value={Number(data.baseSalary)} />
<SalaryRow label="加班费" value={Number(data.overtimePay)} />
<SalaryRow label="津贴" value={Number(data.allowance)} />
<SalaryRow label="扣款" value={Number(data.deduction)} danger />
</div>
</Card>
{/* 确认状态 */}
<Card className="p-4">
{data.confirmedAt ? (
<div className="flex items-center gap-2 text-sm text-green-600">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<Check className="w-4 h-4" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-xs text-gray-400">{new Date(data.confirmedAt).toLocaleString()}</div>
</div>
</div>
) : (
<Button
className="w-full"
onClick={() => confirmMutation.mutate(data.id)}
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
</Button>
)}
</Card>
</>
)}
</div>
)
}