feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { PenTool, Check, AlertCircle } from 'lucide-react'
|
||||
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') || ''
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [devCode, setDevCode] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
api.get(`/portal/contract-confirm/${token}`).then((res: any) => {
|
||||
setData(res.data)
|
||||
}).catch((err: any) => {
|
||||
setError(err.response?.data?.error?.message || '链接无效或已过期')
|
||||
}).finally(() => setLoading(false))
|
||||
} else {
|
||||
setError('缺少 token 参数')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setSendingCode(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/portal/contract-confirm/send-code', { token }) as any
|
||||
setCodeSent(true)
|
||||
setDevCode(res.data?.data?.code || '')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '验证码发送失败')
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.post('/portal/contract-confirm', { token, agreed: true, verifyCode })
|
||||
setConfirmed(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '确认失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-sm font-semibold mb-2">合同签署确认成功</h1>
|
||||
<p className="text-sm text-gray-500">已记录您的签署确认时间和 IP 地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 gap-2 mb-6">
|
||||
<PenTool className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">合同签署确认</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : data ? (
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.orgName} 与 {data.employeeName} 的劳动合同
|
||||
</div>
|
||||
|
||||
{data.contract && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="合同类型" value={data.contract.contractType === 'FIXED' ? '固定期限' : data.contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
|
||||
{data.contract.contractYears > 0 && <Row label="合同期限" value={`${data.contract.contractYears}年`} />}
|
||||
<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={`¥${fmt(Number(data.contract.probationSalary))}`} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
||||
我已阅读合同内容,确认签署
|
||||
</label>
|
||||
|
||||
{/* 验证码区域 */}
|
||||
{agreed && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
className="flex-1 px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || codeSent}
|
||||
className="px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{sendingCode ? '发送中' : codeSent ? '已发送' : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{devCode && (
|
||||
<div className="text-xs text-blue-500">开发模式验证码:{devCode}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting || !verifyCode}>
|
||||
{submitting ? '确认中...' : '确认签署'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 确认后将记录签署时间、IP 地址和设备信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, AlertCircle, Check, RefreshCw } 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 MyContract() {
|
||||
const [resending, setResending] = useState(false)
|
||||
const [resendMsg, setResendMsg] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['my-contract'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/contract') as any
|
||||
return res.data?.data ?? null
|
||||
},
|
||||
})
|
||||
|
||||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||||
const contract = data
|
||||
|
||||
const daysToExpire = contract?.endDate
|
||||
? Math.floor((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
|
||||
const isConfirmed = contract?.attachmentName?.startsWith('confirmed:')
|
||||
|
||||
const handleResend = async () => {
|
||||
setResending(true)
|
||||
setResendMsg('')
|
||||
try {
|
||||
const res = await portalApi.post('/contract-confirm/resend', { contractId: contract?.id }) as any
|
||||
setResendMsg(res.data?.data?.message || '重发成功')
|
||||
} catch (err: any) {
|
||||
setResendMsg(err.response?.data?.error?.message || '重发失败')
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<FileText 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/payslip" className="text-sm text-primary">工资条</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !contract ? (
|
||||
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 到期提醒 */}
|
||||
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
您的合同还有 {daysToExpire} 天到期
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="合同类型" value={contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
|
||||
<Row label="签订方式" value={contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'} />
|
||||
{contract.signDate && <Row label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />}
|
||||
<Row label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
|
||||
{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={`¥${fmt(Number(contract.probationSalary))}`} />}
|
||||
</div>
|
||||
|
||||
{/* 签署确认记录 */}
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">签署记录</h3>
|
||||
{isConfirmed ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" />
|
||||
已确认签署({new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-warning">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
合同尚未确认签署
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{resending ? '重发中...' : '重发确认链接'}
|
||||
</Button>
|
||||
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ClipboardList, Check, FileText, X } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
|
||||
const FILE_TYPES = [
|
||||
{ key: 'ID_CARD_FRONT', label: '身份证正面' },
|
||||
{ key: 'ID_CARD_BACK', label: '身份证反面' },
|
||||
{ key: 'EDUCATION', label: '学历证明' },
|
||||
{ key: 'BANK_CARD', label: '银行卡照片' },
|
||||
{ key: 'OTHER', label: '其他材料' },
|
||||
]
|
||||
|
||||
interface UploadedFile {
|
||||
fileType: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export default function Onboarding() {
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [currentFileType, setCurrentFileType] = useState('ID_CARD_FRONT')
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
idCard: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
address: '',
|
||||
bankCard: '',
|
||||
bankName: '',
|
||||
})
|
||||
|
||||
// 获取链接信息
|
||||
useState(() => {
|
||||
if (token) {
|
||||
api.get(`/portal/onboarding/${token}`).then((res: any) => {
|
||||
setOrgName(res.data.orgName)
|
||||
}).catch((err: any) => {
|
||||
setError(err.response?.data?.error?.message || '链接无效')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('fileType', currentFileType)
|
||||
const res = await api.post(`/portal/onboarding/${token}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as any
|
||||
setUploadedFiles([...uploadedFiles, res.data.data])
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '文件上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
setUploadedFiles(uploadedFiles.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/portal/onboarding', { ...form, token, attachments: uploadedFiles })
|
||||
setSubmitted(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '提交失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-sm font-semibold mb-2">信息提交成功</h1>
|
||||
<p className="text-sm text-gray-500">HR 将审核您的信息,请耐心等待。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 gap-2 mb-6">
|
||||
<ClipboardList className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">入职信息填报</h1>
|
||||
</div>
|
||||
|
||||
{orgName && (
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
欢迎加入 {orgName}!请填写以下信息:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号 *</Label>
|
||||
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="请输入手机号" maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>身份证号 *</Label>
|
||||
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入身份证号" maxLength={18} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>紧急联系人</Label>
|
||||
<Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>紧急联系电话</Label>
|
||||
<Input type="tel" value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>住址</Label>
|
||||
<Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>银行卡号</Label>
|
||||
<Input value={form.bankCard} onChange={(e) => setForm({ ...form, bankCard: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>开户行</Label>
|
||||
<Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<div className="border-t pt-3">
|
||||
<Label>入职材料上传</Label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{FILE_TYPES.map((ft) => (
|
||||
<button
|
||||
key={ft.key}
|
||||
type="button"
|
||||
onClick={() => setCurrentFileType(ft.key)}
|
||||
className={`px-2 py-1 rounded text-xs ${currentFileType === ft.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{ft.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf,.bmp"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-md text-xs text-gray-500 hover:border-primary"
|
||||
>
|
||||
{uploading ? '上传中...' : `点击上传${FILE_TYPES.find(f => f.key === currentFileType)?.label || ''}`}
|
||||
</button>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{uploadedFiles.map((f, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-2 py-1 bg-gray-50 rounded text-xs">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<FileText className="w-3 h-3 flex-shrink-0 text-gray-400" />
|
||||
<span className="truncate">{FILE_TYPES.find(ft => ft.key === f.fileType)?.label || f.fileType}: {f.fileName}</span>
|
||||
</div>
|
||||
<button onClick={() => removeFile(i)} className="text-gray-400 hover:text-danger flex-shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.idCard}>
|
||||
{loading ? '提交中...' : '提交'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 提交后 HR 将审核您的信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
export default function PortalLogin() {
|
||||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [displayedCode, setDisplayedCode] = useState('')
|
||||
|
||||
const handlePasswordLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/portal/login', { phone, password }) as any
|
||||
localStorage.setItem('portalToken', res.data.token)
|
||||
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
|
||||
navigate('/portal/payslip')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/portal/send-code', { phone }) as any
|
||||
setCodeSent(true)
|
||||
setDisplayedCode(res.data.code)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/portal/verify-code', { phone, code }) as any
|
||||
localStorage.setItem('portalToken', res.data.token)
|
||||
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
|
||||
navigate('/portal/payslip')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-base font-bold">用工合规助手 — 员工端</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => { setMode('password'); setError('') }}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'password' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>密码登录</button>
|
||||
<button
|
||||
onClick={() => { setMode('code'); setError('') }}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'code' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>验证码登录</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>验证码</Label>
|
||||
<Input placeholder="6位验证码" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent}>
|
||||
{codeSent ? '已发送' : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{codeSent && displayedCode && (
|
||||
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
验证码:{displayedCode}(开发阶段直接显示,生产环境将发送短信)
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user