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:
@@ -36,6 +36,8 @@ const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCa
|
||||
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
|
||||
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
||||
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
||||
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
||||
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
||||
|
||||
// 平台管理端
|
||||
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
|
||||
@@ -152,6 +154,7 @@ export default function App() {
|
||||
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 平台管理端 */}
|
||||
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
|
||||
@@ -166,6 +169,7 @@ export default function App() {
|
||||
<Route path="/portal/onboarding" element={<PortalLayoutWrapper showNav={false}><Onboarding /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/attendance" element={<PortalLayoutWrapper><MyAttendance /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
|
||||
|
||||
{/* 兜底 */}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
|
||||
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck } from 'lucide-react'
|
||||
import Logo from '../../components/ui/Logo'
|
||||
|
||||
const tabItems = [
|
||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
|
||||
{ path: '/portal/contract', label: '我的合同', icon: FileText },
|
||||
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck },
|
||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
||||
]
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Bot, BookMarked,
|
||||
Bell, ScrollText, Settings,
|
||||
ChevronDown, ChevronRight,
|
||||
Building2, CalendarDays,
|
||||
Building2, CalendarDays, ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import Logo from '../ui/Logo'
|
||||
|
||||
@@ -40,6 +40,7 @@ const navGroups: NavGroup[] = [
|
||||
title: '员工管理',
|
||||
items: [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
|
||||
{ path: '/termination', label: '解聘补偿', icon: UserX },
|
||||
],
|
||||
|
||||
@@ -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="粘贴劳动合同文本..."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user