feat: 实现20260730优化方案全部功能

- AI文件审查:.docx上传提取文本,支持多种文档类型
- 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程
- 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word
- 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面
- 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤
- 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作
- Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型
- 前后端编译验证全部通过
This commit is contained in:
freedakgmail
2026-07-30 10:21:22 +08:00
parent 38b8849332
commit 42e0c650a4
24 changed files with 3639 additions and 35 deletions
+62 -2
View File
@@ -1329,15 +1329,60 @@ function PredictTab() {
)
}
const REVIEW_DOC_TYPES = [
{ value: 'labor_contract', label: '劳动合同' },
{ value: 'rescission', label: '协商解除协议' },
{ value: 'labor_service', label: '劳务协议' },
{ value: 'internship', label: '实习协议' },
{ value: 'nda', label: '保密协议' },
{ value: 'other', label: '其他' },
]
function ReviewTab() {
const [contractText, setContractText] = useState('')
const [result, setResult] = useState<any>(null)
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [docType, setDocType] = useState('labor_contract')
const [fileName, setFileName] = useState('')
const [showSaveModal, setShowSaveModal] = useState(false)
const [saveEmployeeId, setSaveEmployeeId] = useState('')
const [showHistory, setShowHistory] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const ext = file.name.toLowerCase().split('.').pop()
if (ext !== 'docx' && ext !== 'doc') {
toast.error('仅支持 .docx 格式文件')
return
}
if (file.size > 100 * 1024 * 1024) {
toast.error('文件大小不能超过 100MB')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await api.post('/ai/review/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}) as any
if (res.data?.text) {
setContractText(res.data.text)
setFileName(file.name)
toast.success(`已提取文件内容(${res.data.text.length} 字)`)
}
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '文件上传失败')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-list'],
queryFn: async () => {
@@ -1410,8 +1455,23 @@ function ReviewTab() {
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
</div>
)}
<div className="mt-3">
<Label></Label>
<div className="mt-3 space-y-3">
{/* 文件上传区 */}
<div>
<Label></Label>
<div className="flex items-center gap-2">
<Select value={docType} onChange={(e) => setDocType(e.target.value)} className="w-40">
{REVIEW_DOC_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
<input ref={fileInputRef} type="file" accept=".docx,.doc" onChange={handleFileUpload} className="hidden" />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />...</>) : (<><FileText className="w-4 h-4 mr-1" /> .docx </>)}
</Button>
{fileName && <span className="text-xs text-gray-500 truncate max-w-[200px]">{fileName}</span>}
</div>
</div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
placeholder="粘贴劳动合同文本..."
+45 -1
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2 } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -124,9 +124,53 @@ function ConfirmTab() {
},
})
const { data: publishRecords } = useQuery<any[]>({
queryKey: ['attendance-publish-records'],
queryFn: async () => {
const res = await api.get('/attendance/publish-records') as any
return res.data
},
})
const publishMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/attendance/publish', { month }) as any
return res.data
},
onSuccess: () => {
toast.success(`${month}月考勤表已发布`)
queryClient.invalidateQueries({ queryKey: ['attendance-publish-records'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'),
})
const cancelPublishMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.post(`/attendance/publish/${id}/cancel`) as any
return res.data
},
onSuccess: () => {
toast.success('已取消发布')
queryClient.invalidateQueries({ queryKey: ['attendance-publish-records'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消失败'),
})
const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
return (
<div className="space-y-3">
<div className="flex items-center gap-2 justify-end">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={() => cancelPublishMutation.mutate(currentPublish.id)}>
<X className="w-3.5 h-3.5 mr-1" />
</Button>
) : (
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
+128 -22
View File
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles, Repeat, XCircle, Loader2 } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -41,6 +41,8 @@ export default function Dashboard() {
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [drillDownType, setDrillDownType] = useState<string | null>(null)
const [showExpiringModal, setShowExpiringModal] = useState(false)
const [dismissedExpiring, setDismissedExpiring] = useState(false)
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
@@ -441,30 +443,45 @@ export default function Dashboard() {
</div>
{/* 合同到期预警 */}
{expiringContracts && expiringContracts.length > 0 && (
<Link to="/roster?contractStatus=expiring">
<Card className="border-danger/30 bg-danger/5 hover:bg-danger/10 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-danger" />
<div>
<div className="text-sm font-medium text-danger"></div>
<div className="text-xs text-gray-500 mt-0.5">
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
<span key={c.employeeId}>
{i > 0 && '、'}
{c.employeeName}
<span className="text-danger ml-1">({c.daysLeft})</span>
</span>
))}
{expiringContracts.length > 3 && <span className="text-gray-500"> {expiringContracts.length}</span>}
</div>
{expiringContracts && expiringContracts.length > 0 && !dismissedExpiring && (
<Card className="border-danger/30 bg-danger/5">
<div className="flex items-center justify-between">
<div
className="flex items-center gap-2 cursor-pointer flex-1"
onClick={() => setShowExpiringModal(true)}
>
<AlertCircle className="w-5 h-5 text-danger" />
<div>
<div className="text-sm font-medium text-danger"></div>
<div className="text-xs text-gray-500 mt-0.5">
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
<span key={c.employeeId}>
{i > 0 && '、'}
{c.employeeName}
<span className="text-danger ml-1">({c.daysLeft})</span>
</span>
))}
{expiringContracts.length > 3 && <span className="text-gray-500"> {expiringContracts.length}</span>}
</div>
</div>
<ArrowRight className="w-4 h-4 text-danger" />
</div>
</Card>
</Link>
<div className="flex items-center gap-2">
<button
onClick={() => setShowExpiringModal(true)}
className="text-xs text-primary hover:underline"
>
</button>
<button
onClick={() => setDismissedExpiring(true)}
className="text-gray-400 hover:text-gray-600 p-1"
title="稍后提醒"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</Card>
)}
{/* 本月工作动态 + 风险分布 左右两列 */}
@@ -1015,6 +1032,95 @@ export default function Dashboard() {
)}
</div>
)}
{/* 合同到期处理弹窗 */}
{showExpiringModal && expiringContracts && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowExpiringModal(false)}>
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-danger" />
<h3 className="text-sm font-medium"></h3>
<span className="text-xs text-gray-500">({expiringContracts.length})</span>
</div>
<button onClick={() => setShowExpiringModal(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-2">
{expiringContracts.map((c: any) => (
<div key={c.employeeId} className="flex items-center gap-3 p-3 rounded-md border border-gray-200">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{c.employeeName}</div>
<div className="text-xs text-gray-500">
{c.department} · {c.endDate ? new Date(c.endDate).toLocaleDateString('zh-CN') : '未知'}
<span className={`ml-2 ${c.daysLeft <= 7 ? 'text-danger' : c.daysLeft <= 30 ? 'text-warning' : 'text-gray-500'}`}>
{c.daysLeft}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
api.post('/work-processes', {
type: 'RENEW',
title: `合同续签-${c.employeeName}`,
employeeId: c.employeeId,
formData: { employeeId: c.employeeId, oldContractId: c.contractId },
status: 'DRAFT',
}).then(() => {
toast.success(`已创建 ${c.employeeName} 的续签流程`)
setShowExpiringModal(false)
}).catch((err) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
})
}}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-primary hover:bg-primary/10 transition-colors"
>
<Repeat className="w-3 h-3" />
</button>
<button
onClick={() => {
api.post('/work-processes', {
type: 'TERMINATE',
title: `合同终止-${c.employeeName}`,
employeeId: c.employeeId,
formData: { employeeId: c.employeeId, contractId: c.contractId },
status: 'DRAFT',
}).then(() => {
toast.success(`已创建 ${c.employeeName} 的终止流程`)
setShowExpiringModal(false)
}).catch((err) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
})
}}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-danger hover:bg-danger/10 transition-colors"
>
<XCircle className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t flex items-center justify-between">
<Link
to="/roster?contractStatus=expiring"
onClick={() => setShowExpiringModal(false)}
className="text-xs text-primary hover:underline"
>
</Link>
<Button size="sm" variant="secondary" onClick={() => setShowExpiringModal(false)}>
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
+74
View File
@@ -547,6 +547,27 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const publishPayslipMutation = useMutation({
mutationFn: () => api.post(`/payroll2/batches/${batchId}/publish`),
onSuccess: (res: any) => {
toast.success(`已发布 ${res.data?.published || 0} 条工资条`)
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'),
})
const [showScheduleModal, setShowScheduleModal] = useState(false)
const [scheduleDate, setScheduleDate] = useState('')
const schedulePayslipMutation = useMutation({
mutationFn: () => api.post(`/payroll2/batches/${batchId}/schedule`, { scheduledAt: scheduleDate }),
onSuccess: (res: any) => {
toast.success(`已设定定时发送 ${res.data?.scheduled || 0} 条工资条`)
setShowScheduleModal(false)
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '设定失败'),
})
const importOvertimeMutation = useMutation({
mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`),
onSuccess: (res: any) => {
@@ -890,6 +911,24 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
>
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
</Button>
<Button
size="sm"
onClick={async () => {
if (await confirm({ title: '发布工资条', message: `确认发布 ${batch.month} 月工资条?发布后员工可在员工端查看。`, variant: 'primary' })) {
publishPayslipMutation.mutate()
}
}}
disabled={publishPayslipMutation.isPending}
>
{publishPayslipMutation.isPending ? '发布中...' : '发布工资条'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowScheduleModal(true)}
>
<Clock className="w-4 h-4 mr-1" />
</Button>
</div>
)}
</div>
@@ -1042,6 +1081,41 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</table>
</div>
</Card>
{/* 定时发送弹窗 */}
{showScheduleModal && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowScheduleModal(false)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium"></h3>
<button onClick={() => setShowScheduleModal(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<p className="text-xs text-gray-500 mb-3"></p>
<div className="space-y-3">
<div>
<Label></Label>
<Input
type="datetime-local"
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
/>
</div>
<Button
size="sm"
onClick={() => {
if (!scheduleDate) { toast.error('请选择发送时间'); return }
schedulePayslipMutation.mutate()
}}
disabled={schedulePayslipMutation.isPending}
>
{schedulePayslipMutation.isPending ? '设定中...' : '确认定时发送'}
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
+296 -6
View File
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { toast } from 'sonner'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
const CATEGORY_LABELS: Record<string, string> = {
@@ -68,6 +70,37 @@ const VARIABLE_LABELS: Record<string, string> = {
* 用工文本模板库页面
*/
export default function Templates() {
const [tab, setTab] = useState<'system' | 'enterprise'>('system')
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
{/* Tab 切换 */}
<div className="flex gap-2">
<button
onClick={() => setTab('system')}
className={`px-4 py-1.5 text-sm rounded-lg ${tab === 'system' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
</button>
<button
onClick={() => setTab('enterprise')}
className={`px-4 py-1.5 text-sm rounded-lg ${tab === 'enterprise' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Building2 className="w-3.5 h-3.5 inline mr-1" />
</button>
</div>
{tab === 'system' ? <SystemTemplates /> : <EnterpriseTemplates />}
</div>
)
}
function SystemTemplates() {
const [category, setCategory] = useState<string>('')
const [selected, setSelected] = useState<any>(null)
const [rendered, setRendered] = useState<string>('')
@@ -137,10 +170,6 @@ export default function Templates() {
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="text-sm text-gray-500"></p>
<div className="flex items-center gap-2">
@@ -270,3 +299,264 @@ export default function Templates() {
</div>
)
}
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const [category, setCategory] = useState<string>('')
const [showEdit, setShowEdit] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
const [selected, setSelected] = useState<any>(null)
const [rendered, setRendered] = useState('')
const [variables, setVariables] = useState<Record<string, string>>({})
const { data: list, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates', category],
queryFn: async () => {
const params = category ? `?category=${category}` : ''
const res = await api.get(`/enterprise-templates${params}`) as any
return res.data
},
})
const { data: detail } = useQuery<any>({
queryKey: ['enterprise-template-detail', selected?.id],
queryFn: async () => {
const res = await api.get(`/enterprise-templates/${selected.id}`) as any
return res.data
},
enabled: !!selected,
})
const saveMutation = useMutation({
mutationFn: async (data: any) => {
if (editItem) {
const res = await api.put(`/enterprise-templates/${editItem.id}`, data) as any
return res.data
} else {
const res = await api.post('/enterprise-templates', data) as any
return res.data
}
},
onSuccess: () => {
toast.success(editItem ? '已更新' : '已创建')
queryClient.invalidateQueries({ queryKey: ['enterprise-templates'] })
setShowEdit(false)
setEditItem(null)
setForm({ name: '', category: 'CONTRACT', description: '', content: '' })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/enterprise-templates/${id}`)
},
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['enterprise-templates'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const handleRender = async () => {
if (!selected) return
try {
const res = await api.post(`/enterprise-templates/${selected.id}/render`, { variables }) as any
setRendered(res.data.content)
} catch {
toast.error('渲染失败')
}
}
const handleDownloadWord = async () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载')
} catch {
toast.error('下载失败')
}
}
const handleEdit = (item: any) => {
setEditItem(item)
setForm({ name: item.name, category: item.category, description: item.description || '', content: item.content })
setShowEdit(true)
}
const handleAdd = () => {
setEditItem(null)
setForm({ name: '', category: 'CONTRACT', description: '', content: '' })
setShowEdit(true)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500"> Word </p>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex gap-2">
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1 text-xs rounded-lg ${category === c ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{c === '' ? '全部' : CATEGORY_LABELS[c]}
</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无企业模板" description="点击「新建模板」创建您的第一个企业文本模板" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{list.map((t: any) => (
<Card key={t.id} className="hover:shadow-md transition-shadow">
<div onClick={() => { setSelected(t); setRendered(''); setVariables({}) }} className="cursor-pointer">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded text-xs bg-primary/10 text-primary">{CATEGORY_LABELS[t.category]}</span>
<span className="text-sm font-medium truncate flex-1">{t.name}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
{t.variables?.slice(0, 4).map((v: string) => (
<span key={v} className="px-1 py-0.5 rounded bg-gray-100">{VARIABLE_LABELS[v] || v}</span>
))}
{t.variables?.length > 4 && <span>+{t.variables.length - 4}</span>}
</div>
</div>
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-100">
<button onClick={() => handleEdit(t)} className="flex items-center gap-1 text-xs text-gray-500 hover:text-primary">
<Edit className="w-3 h-3" />
</button>
<button
onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(t.id) }}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</Card>
))}
</div>
)}
{/* 编辑弹窗 */}
<Modal open={showEdit} onClose={() => setShowEdit(false)} title={editItem ? '编辑模板' : '新建模板'} size="lg">
<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>
<Select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="简要描述模板用途" />
</div>
<div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[200px] font-mono"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="输入模板内容,使用 {{变量名}} 作为变量占位符,如 {{employeeName}}、{{companyName}}"
/>
</div>
<div className="text-xs text-gray-500">
<code className="px-1 bg-gray-100 rounded">{'{{变量名}}'}</code> <code className="px-1 bg-gray-100 rounded">{'{{employeeName}}'}</code><code className="px-1 bg-gray-100 rounded">{'{{companyName}}'}</code>
</div>
<Button onClick={() => saveMutation.mutate(form)} disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</Modal>
{/* 详情弹窗 */}
{selected && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelected(null)}>
<Card className="max-w-3xl w-full max-h-[85vh] overflow-y-auto">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selected.name}</h2>
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
{detail?.variables && detail.variables.length > 0 && (
<div className="mb-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="grid grid-cols-2 gap-2">
{detail.variables.map((v: string) => (
<div key={v}>
<label className="text-xs text-gray-500">{VARIABLE_LABELS[v] || v}</label>
<input
value={variables[v] || ''}
onChange={e => setVariables(prev => ({ ...prev, [v]: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={`输入${VARIABLE_LABELS[v] || v}`}
/>
</div>
))}
</div>
<Button size="sm" onClick={handleRender}></Button>
</div>
)}
{rendered ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<div className="flex gap-2">
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="w-3 h-3" /> Word
</button>
<button onClick={() => { navigator.clipboard.writeText(rendered); toast.success('已复制') }} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
</div>
) : detail?.content ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="w-3 h-3" /> Word
</button>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
</div>
) : null}
</div>
</Card>
</div>
)}
</div>
)
}
+501
View File
@@ -0,0 +1,501 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye,
} 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 Modal from '../components/ui/Modal'
const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
TERMINATE: XCircle, RESCIND: UserX, LEAVING_CERT: FileMinus,
FLEXIBLE: Briefcase,
}
const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同' },
ONBOARD: { label: '员工入职', description: '办理员工入职手续' },
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署' },
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更' },
CONFIRM: { label: '员工转正', description: '试用期员工转正' },
CHANGE: { label: '合同变更', description: '变更合同内容' },
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
TERMINATE: { label: '合同终止', description: '合同到期终止' },
RESCIND: { label: '合同解除', description: '协商或单方解除合同' },
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
}
// 各流程类型的表单字段配置
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea'; options?: string[] }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'monthlySalary', label: '月薪', type: 'number' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
],
ONBOARD: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
],
CUSTOM_CONTRACT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
],
INFO_SUBMIT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'address', label: '地址', type: 'text' },
{ key: 'emergencyContact', label: '紧急联系人', type: 'text' },
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
],
CONFIRM: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'confirmDate', label: '转正日期', type: 'date' },
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
],
CHANGE: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'newEndDate', label: '新到期日期', type: 'date' },
],
RENEW: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date' },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
],
SUSPEND: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'suspendDate', label: '中止日期', type: 'date' },
],
INCOME_CERT: [
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'terminateDate', label: '终止日期', type: 'date' },
],
RESCIND: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'rescindDate', label: '解除日期', type: 'date' },
],
LEAVING_CERT: [
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'leaveDate', label: '离职日期', type: 'date' },
],
FLEXIBLE: [
{ key: 'name', label: '姓名', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date' },
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
{ key: 'payMethod', label: '计酬方式', type: 'text' },
],
}
export default function WorkProcess() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>('')
const [formData, setFormData] = useState<Record<string, any>>({})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const { data: listData, isLoading } = useQuery({
queryKey: ['work-processes', filterType, filterStatus],
queryFn: async () => {
const params: any = {}
if (filterType) params.type = filterType
if (filterStatus) params.status = filterStatus
const res = await api.get('/work-processes', { params }) as any
return res.data
},
})
const createMutation = useMutation({
mutationFn: async (data: any) => {
const res = await api.post('/work-processes', data) as any
return res.data
},
onSuccess: () => {
toast.success('已创建草稿')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCreate(false)
setFormData({})
setSelectedType('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
const submitMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.post(`/work-processes/${id}/submit`) as any
return res.data
},
onSuccess: () => {
toast.success('已提交并执行')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
})
const cancelMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.post(`/work-processes/${id}/cancel`) as any
return res.data
},
onSuccess: () => {
toast.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '撤销失败'),
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/work-processes/${id}`)
},
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const previewMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.get(`/work-processes/${id}/preview`) as any
return res.data
},
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '预览失败'),
})
const handleCreate = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
createMutation.mutate({
type: selectedType,
title: PROCESS_TYPES[selectedType].label,
formData,
status: 'DRAFT',
})
}
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
}
const items = listData?.items || []
return (
<div className="space-y-4">
{/* 发起办理 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
<button
key={key}
onClick={() => {
setSelectedType(key)
setShowCreate(true)
setFormData({})
}}
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<div className="min-w-0">
<div className="text-xs font-medium text-gray-900">{config.label}</div>
<div className="text-[10px] text-gray-500 truncate">{config.description}</div>
</div>
</button>
)
})}
</div>
</Card>
{/* 办理记录 */}
<Card>
<div className="flex items-center gap-3 mb-4">
<h3 className="text-sm font-medium"></h3>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32">
<option value=""></option>
{Object.entries(PROCESS_TYPES).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</Select>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-32">
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</Select>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
) : items.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"></div>
) : (
<div className="space-y-2">
{items.map((item: any) => {
const Icon = PROCESS_ICONS[item.type] || FileText
const statusCfg = STATUS_CONFIG[item.status] || STATUS_CONFIG.DRAFT
return (
<div
key={item.id}
className="flex items-center gap-3 p-3 rounded-md border border-gray-200 hover:bg-gray-50 cursor-pointer"
onClick={() => setDetailId(item.id)}
>
<Icon className="w-4 h-4 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{item.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{item.employee ? `${item.employee.name} · ${item.employee.department}` : '未关联员工'}
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300" />
</div>
)
})}
</div>
)}
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => { setShowCreate(false); setFormData({}); setSelectedType('') }} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
<button
key={key}
onClick={() => setSelectedType(key)}
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<div>
<div className="text-xs font-medium">{config.label}</div>
<div className="text-[10px] text-gray-500">{config.description}</div>
</div>
</button>
)
})}
</div>
) : (
<div className="space-y-3">
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
{(FORM_FIELDS[selectedType] || []).map(field => (
<div key={field.key}>
<Label>{field.label}</Label>
{field.type === 'select' ? (
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
<option value=""></option>
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</Select>
) : field.type === 'textarea' ? (
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[80px]"
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
<div className="flex items-center gap-2 pt-2">
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
稿
</Button>
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
</Button>
</div>
</div>
)}
</Modal>
{/* 详情弹窗 */}
<Modal open={!!detailId} onClose={() => { setDetailId(null); setPreviewContent(null) }} title="办理详情" size="lg">
<DetailContent
id={detailId}
previewContent={previewContent}
onPreview={(id) => previewMutation.mutate(id)}
onSubmit={(id) => submitMutation.mutate(id)}
onCancel={(id) => cancelMutation.mutate(id)}
onDelete={(id) => deleteMutation.mutate(id)}
loading={submitMutation.isPending || cancelMutation.isPending}
/>
</Modal>
</div>
)
}
function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDelete, loading }: {
id: string | null
previewContent: string | null
onPreview: (id: string) => void
onSubmit: (id: string) => void
onCancel: (id: string) => void
onDelete: (id: string) => void
loading: boolean
}) {
const { data, isLoading } = useQuery({
queryKey: ['work-process', id],
queryFn: async () => {
const res = await api.get(`/work-processes/${id}`) as any
return res.data
},
enabled: !!id,
})
if (isLoading || !data) return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.DRAFT
const Icon = PROCESS_ICONS[data.type] || FileText
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-primary" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{data.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : '未关联员工'}
</div>
</div>
</div>
{/* 表单数据 */}
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="bg-gray-50 rounded-md p-3 space-y-1">
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
<div key={key} className="flex text-xs">
<span className="text-gray-500 w-28 shrink-0">{key}</span>
<span className="text-gray-900">{String(value)}</span>
</div>
))}
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400"></span>}
</div>
</div>
{/* 文书预览 */}
{previewContent && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<pre className="bg-gray-50 rounded-md p-3 text-xs whitespace-pre-wrap max-h-[300px] overflow-y-auto">{previewContent}</pre>
</div>
)}
{/* 生成的文书 */}
{data.documents && data.documents.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="space-y-1">
{data.documents.map((doc: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<FileText className="w-3 h-3 text-gray-400" />
<span>{doc.name}</span>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-2 pt-2 border-t">
{data.status === 'DRAFT' && (
<>
<Button size="sm" onClick={() => onPreview(data.id)} variant="secondary">
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => onSubmit(data.id)} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" variant="danger" onClick={() => onDelete(data.id)}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</>
)}
{!['COMPLETED', 'CANCELLED'].includes(data.status) && data.status !== 'DRAFT' && (
<Button size="sm" variant="secondary" onClick={() => onCancel(data.id)} disabled={loading}>
<X className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
)
}
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Loader2, CalendarCheck } from 'lucide-react'
import api from '../../lib/api'
export default function MyAttendance() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const { data, isLoading } = useQuery({
queryKey: ['portal-attendance', month],
queryFn: async () => {
const res = await api.get('/portal/attendance', { params: { month } }) as any
return res.data
},
})
const records = data?.records || []
const published = data?.published || false
// 生成月份列表(最近6个月)
const months: string[] = []
const now = new Date()
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<CalendarCheck className="w-5 h-5 text-primary" />
<h1 className="text-base font-bold"></h1>
</div>
{/* 月份选择 */}
<div className="flex gap-2 overflow-x-auto pb-1">
{months.map(m => (
<button
key={m}
onClick={() => setMonth(m)}
className={`px-3 py-1.5 rounded-md text-xs whitespace-nowrap transition-colors ${
month === m
? 'bg-primary text-white font-medium'
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{m}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : !published ? (
<div className="bg-white rounded-lg p-8 text-center">
<p className="text-sm text-gray-400">{month} </p>
</div>
) : records.length === 0 ? (
<div className="bg-white rounded-lg p-8 text-center">
<p className="text-sm text-gray-400"></p>
</div>
) : (
<div className="bg-white rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100">
<h2 className="text-sm font-medium">{data?.title || `${month} 月考勤表`}</h2>
</div>
<div className="divide-y divide-gray-50">
{records.map((record: any) => (
<div key={record.id} className="flex items-center px-4 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-900">
{new Date(record.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
</div>
<div className="text-xs text-gray-500">
{record.checkInTime ? `上班 ${record.checkInTime}` : '未打卡'}
{record.checkOutTime ? ` · 下班 ${record.checkOutTime}` : ''}
</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded ${
record.status === 'NORMAL' ? 'bg-green-50 text-safe' :
record.status === 'LATE' ? 'bg-amber-50 text-amber-700' :
record.status === 'ABSENT' ? 'bg-red-50 text-red-700' :
record.status === 'LEAVE' ? 'bg-blue-50 text-blue-700' :
'bg-gray-50 text-gray-600'
}`}>
{record.statusText || record.status || '未知'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}