feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强

- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
This commit is contained in:
freedakgmail
2026-07-29 08:35:29 +08:00
parent d020d04a8a
commit fb36b10402
45 changed files with 3756 additions and 169 deletions
+2
View File
@@ -35,6 +35,7 @@ const AutoLogin = lazy(() => import('./pages/portal/AutoLogin'))
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
const CalendarPage = lazy(() => import('./pages/Calendar'))
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
@@ -103,6 +104,7 @@ export default function App() {
<Route path="/evidence" element={<ProtectedRoute><AdminLayout><Evidence /></AdminLayout></ProtectedRoute>} />
<Route path="/policies" element={<ProtectedRoute><AdminLayout><Policies /></AdminLayout></ProtectedRoute>} />
<Route path="/attendance" element={<ProtectedRoute><AdminLayout><Attendance /></AdminLayout></ProtectedRoute>} />
<Route path="/calendar" element={<ProtectedRoute><AdminLayout><CalendarPage /></AdminLayout></ProtectedRoute>} />
<Route path="/templates" element={<ProtectedRoute><AdminLayout><Templates /></AdminLayout></ProtectedRoute>} />
<Route path="/audit" element={<ProtectedRoute><AdminLayout><AuditLog /></AdminLayout></ProtectedRoute>} />
<Route path="/notifications" element={<ProtectedRoute><AdminLayout><Notifications /></AdminLayout></ProtectedRoute>} />
@@ -13,7 +13,7 @@ import {
Bot, BookMarked,
Bell, ScrollText, Settings,
ChevronDown, ChevronRight,
Building2,
Building2, CalendarDays,
} from 'lucide-react'
import Logo from '../ui/Logo'
@@ -33,6 +33,7 @@ const navGroups: NavGroup[] = [
title: '工作台',
items: [
{ path: '/', label: '总览', icon: LayoutDashboard },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
],
},
{
+1 -1
View File
@@ -16,7 +16,7 @@ export default function Pagination({
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
pageSizeOptions = [10, 20, 50, 100, 200],
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
+323 -2
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download, TrendingUp, UserCheck, Phone } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@@ -14,7 +14,89 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
function parseInlineBold(text: string): TextRun[] {
const runs: TextRun[] = []
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g
let lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
}
if (match[2]) {
runs.push(new TextRun({ text: match[2], bold: true }))
} else if (match[3]) {
runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 }))
}
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
runs.push(new TextRun({ text: text.slice(lastIndex) }))
}
return runs.length ? runs : [new TextRun({ text })]
}
/** 导出 Markdown 文本为 Word 文档 */
async function exportMarkdownToWord(markdown: string, fileName: string) {
const lines = markdown.split('\n')
const children: (Paragraph | Table)[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) { i++; continue }
if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) {
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean)
i += 2
const rows: TableRow[] = []
rows.push(new TableRow({
children: headerCells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })],
shading: { fill: 'F3F4F6' },
})),
}))
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean)
rows.push(new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text })] })],
})),
}))
i++
}
children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
continue
}
if (line.startsWith('### ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] }))
} else if (line.startsWith('## ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] }))
} else if (line.startsWith('# ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] }))
} else if (line.startsWith('> ')) {
children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } }))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } }))
} else if (/^\d+\.\s/.test(line)) {
children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } }))
} else if (line === '---' || line === '***') {
children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } }))
} else {
children.push(new Paragraph({ children: parseInlineBold(line) }))
}
i++
}
const doc = new Document({
numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] },
sections: [{ children }],
})
const blob = await Packer.toBlob(doc)
saveAs(blob, fileName)
}
// 通用 AI 历史记录 hook
function useAIHistory(type: 'predict' | 'review' | 'case') {
@@ -94,6 +176,7 @@ export default function AIAssistant() {
{ key: 'predict', label: '风险预测', icon: Sparkles },
{ key: 'review', label: '合同审查', icon: FileSearch },
{ key: 'case', label: '案例匹配', icon: Scale },
{ key: 'hr-report', label: '人力报告', icon: TrendingUp },
{ key: 'knowledge', label: '知识库', icon: BookOpen },
]
@@ -129,6 +212,7 @@ export default function AIAssistant() {
{tab === 'predict' && <PredictTab />}
{tab === 'review' && <ReviewTab />}
{tab === 'case' && <CaseTab />}
{tab === 'hr-report' && <HRReportTab />}
{tab === 'knowledge' && <KnowledgeTab />}
</div>
)
@@ -144,6 +228,8 @@ function ChatTab() {
const [recording, setRecording] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [currentConvId, setCurrentConvId] = useState<string | null>(null)
const [showConsultModal, setShowConsultModal] = useState(false)
const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' })
const scrollRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<any>(null)
const saveTimerRef = useRef<any>(null)
@@ -161,6 +247,21 @@ function ChatTab() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
})
const consultMutation = useMutation({
mutationFn: async (data: typeof consultForm) => {
const res = await api.post('/ai/consultation', data) as any
return res.data
},
onSuccess: () => {
toast.success('已提交咨询请求,专业律师将尽快与您联系')
setShowConsultModal(false)
setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' })
},
onError: (err: any) => {
toast.error(err?.message || '提交失败,请稍后重试')
},
})
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}, [messages])
@@ -331,6 +432,7 @@ function ChatTab() {
<div className="flex items-center gap-2 pb-2 border-b">
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowConsultModal(true)}><UserCheck className="w-4 h-4 mr-1" /></Button>
{conversations && conversations.length > 0 && (
<span className="text-xs text-gray-400">{conversations.length} </span>
)}
@@ -422,6 +524,66 @@ function ChatTab() {
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
{/* 转人工咨询 Modal */}
{showConsultModal && (
<Modal open={true} title="联系专业律师" onClose={() => setShowConsultModal(false)}>
<div className="space-y-3">
<div className="rounded-md bg-blue-50 border border-blue-200 p-3 text-xs text-blue-700">
<p className="font-medium mb-1"></p>
<p>· <strong></strong>线</p>
<p>· <strong></strong></p>
<p>· <strong></strong></p>
<p className="mt-1"> 24 </p>
</div>
<div>
<Label></Label>
<Select value={consultForm.type} onChange={(e) => setConsultForm({ ...consultForm, type: e.target.value })}>
<option value="LEGAL"></option>
<option value="ARBITRATION"></option>
<option value="COURT"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={consultForm.title} onChange={(e) => setConsultForm({ ...consultForm, title: 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 focus:border-transparent text-sm min-h-[80px] resize-y"
value={consultForm.description}
onChange={(e) => setConsultForm({ ...consultForm, description: e.target.value })}
placeholder="请详细描述您遇到的法律问题、涉及的员工情况等"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={consultForm.contactName} onChange={(e) => setConsultForm({ ...consultForm, contactName: e.target.value })} placeholder="您的姓名" />
</div>
<div>
<Label></Label>
<Input value={consultForm.contactPhone} onChange={(e) => setConsultForm({ ...consultForm, contactPhone: e.target.value })} placeholder="手机号码" maxLength={11} />
</div>
</div>
<div>
<Label></Label>
<Input value={consultForm.remark} onChange={(e) => setConsultForm({ ...consultForm, remark: e.target.value })} placeholder="其他需要说明的信息" />
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowConsultModal(false)}></Button>
<Button
size="sm"
onClick={() => consultMutation.mutate(consultForm)}
disabled={consultMutation.isPending || !consultForm.title || !consultForm.description || !consultForm.contactName || !consultForm.contactPhone}
>
{consultMutation.isPending ? '提交中...' : '提交咨询'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
@@ -1672,3 +1834,162 @@ function KnowledgeTab() {
</div>
)
}
function HRReportTab() {
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const handleGenerate = async () => {
if (loading) return
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setResult('')
try {
const token = useAuthStore.getState().accessToken
const url = import.meta.env.DEV
? `http://localhost:3000/api/v1/ai/hr-report-stream`
: `/api/v1/ai/hr-report-stream`
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
setResult(accumulated)
}
if (parsed.error) {
throw new Error(parsed.error)
}
} catch (parseErr: any) {
if (parseErr instanceof SyntaxError) continue
throw parseErr
}
}
}
}
setResult(accumulated)
}
} catch (err: any) {
if (err.name !== 'AbortError') {
toast.error(err.message || '生成报告失败')
}
} finally {
setLoading(false)
}
}
const handleExport = async () => {
if (!result) return
try {
await exportMarkdownToWord(result, `人力分析报告_${new Date().toISOString().slice(0, 10)}.docx`)
toast.success('Word 文档已导出')
} catch {
toast.error('导出失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-medium flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-primary" />
AI
</h2>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div className="flex items-center gap-2">
{result && !loading && (
<button
onClick={handleExport}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Download className="w-3.5 h-3.5" />
Word
</button>
)}
<Button size="sm" onClick={handleGenerate} disabled={loading}>
{loading ? (
<><Loader2 className="w-4 h-4 mr-1 animate-spin" />...</>
) : (
<><Sparkles className="w-4 h-4 mr-1" /></>
)}
</Button>
</div>
</div>
{!result && !loading && (
<div className="text-center py-12 text-gray-400">
<TrendingUp className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm">"生成报告"AI </p>
</div>
)}
{loading && !result && (
<div className="text-center py-12">
<Loader2 className="w-8 h-8 mx-auto mb-3 text-primary animate-spin" />
<p className="text-sm text-gray-500">AI ...</p>
</div>
)}
{result && (
<div className="prose prose-sm max-w-none
prose-headings:text-gray-800 prose-headings:font-semibold
prose-h1:text-lg prose-h1:border-b prose-h1:pb-2 prose-h1:border-gray-200
prose-h2:text-base prose-h2:mt-4
prose-h3:text-sm prose-h3:mt-3
prose-p:text-gray-600 prose-p:leading-relaxed
prose-li:text-gray-600 prose-li:leading-relaxed
prose-strong:text-gray-800
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{result}
</ReactMarkdown>
</div>
)}
</Card>
</div>
)
}
+674 -16
View File
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Upload } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane } 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'
import EmptyState from '../components/ui/EmptyState'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
@@ -13,17 +15,88 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string;
DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle },
}
/**
* 考勤确认管理页面
*/
const ATTENDANCE_STATUS: Record<string, string> = {
NORMAL: '正常',
LATE: '迟到',
EARLY_LEAVE: '早退',
ABSENT: '缺勤',
LEAVE: '请假',
BUSINESS_TRIP: '出差',
UNREGISTERED: '未打卡',
}
const LEAVE_TYPES: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const TABS = [
{ key: 'confirm', label: '考勤确认', icon: CalendarCheck },
{ key: 'shifts', label: '班次管理', icon: Clock },
{ key: 'schedule', label: '排班', icon: Calendar },
{ key: 'daily', label: '每日出勤', icon: Users },
{ key: 'monthly', label: '月度报表', icon: BarChart3 },
{ key: 'leaves', label: '休假记录', icon: Plane },
]
export default function Attendance() {
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState('confirm')
return (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
{/* Tab 导航 */}
<div className="flex flex-wrap gap-1 border-b border-gray-200">
{TABS.map(tab => {
const Icon = tab.icon
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
{activeTab === 'monthly' && <MonthlyTab />}
{activeTab === 'leaves' && <LeavesTab />}
</div>
)
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [filterDepartment, setFilterDepartment] = useState('')
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month],
queryKey: ['attendance', month, filterDepartment],
queryFn: async () => {
const res = await api.get(`/attendance?month=${month}`) as any
const params: any = { month }
if (filterDepartment) params.department = filterDepartment
const res = await api.get('/attendance', { params }) as any
return res.data
},
})
@@ -36,16 +109,25 @@ export default function Attendance() {
},
})
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="flex items-center gap-2 justify-end">
<select
value={filterDepartment}
onChange={e => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
@@ -54,7 +136,6 @@ export default function Attendance() {
/>
</div>
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
@@ -117,3 +198,580 @@ export default function Attendance() {
</div>
)
}
// ========== 班次管理 Tab ==========
function ShiftsTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [editShift, setEditShift] = useState<any>(null)
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
const { data: shifts, isLoading } = useQuery<any>({
queryKey: ['shifts'],
queryFn: async () => {
const res = await api.get('/attendance/shifts') as any
return res.data
},
})
const saveMutation = useMutation({
mutationFn: async (data: any) => {
if (editShift) {
return api.put(`/attendance/shifts/${editShift.id}`, data)
}
return api.post('/attendance/shifts', data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts'] })
setShowAdd(false)
setEditShift(null)
setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/shifts/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shifts'] }),
})
const handleSubmit = () => {
if (!form.name.trim()) return toast.error('请输入班次名称')
saveMutation.mutate(form)
}
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !shifts || shifts.length === 0 ? (
<EmptyState title="暂无班次" description="请先创建班次" />
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{shifts.map((s: any) => (
<Card key={s.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ background: s.color }} />
<span className="font-medium text-sm">{s.name}</span>
</div>
<div className="flex gap-1">
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}></button>
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(s.id) }}></button>
</div>
</div>
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
<div>{s.startTime} {s.endTime}</div>
<div>{s.flexibleMinutes} {s.restMinutes} </div>
</div>
</Card>
))}
</div>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title={editShift ? '编辑班次' : '新增班次'}>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:早班、白班、夜班" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="time" value={form.startTime} onChange={e => setForm({ ...form, startTime: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="time" value={form.endTime} onChange={e => setForm({ ...form, endTime: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.flexibleMinutes} onChange={e => setForm({ ...form, flexibleMinutes: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.restMinutes} onChange={e => setForm({ ...form, restMinutes: Number(e.target.value) })} />
</div>
</div>
<div>
<Label></Label>
<input type="color" value={form.color} onChange={e => setForm({ ...form, color: e.target.value })} className="h-9 w-16 rounded border border-gray-200" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAdd(false)}></Button>
<Button onClick={handleSubmit} disabled={saveMutation.isPending}>{saveMutation.isPending ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
</div>
)
}
// ========== 排班 Tab ==========
function ScheduleTab() {
const queryClient = useQueryClient()
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [showAssign, setShowAssign] = useState(false)
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
queryFn: async () => {
const res = await api.get('/attendance/shifts') as any
return res.data
},
})
const { data: assignments, isLoading } = useQuery<any>({
queryKey: ['shift-assignments', date],
queryFn: async () => {
const res = await api.get(`/attendance/shift-assignments?date=${date}`) as any
return res.data
},
})
const { data: dailyData } = useQuery<any>({
queryKey: ['daily-attendance', date],
queryFn: async () => {
const res = await api.get(`/attendance/daily?date=${date}`) as any
return res.data
},
})
const batchAssignMutation = useMutation({
mutationFn: (items: any[]) => api.post('/attendance/shift-assignments/batch', { items }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
setShowAssign(false)
setSelectedEmployeeIds(new Set())
setSelectedShiftId('')
toast.success('排班成功')
},
})
const deleteAssignmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/shift-assignments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
},
})
const handleBatchAssign = () => {
if (!selectedShiftId) return toast.error('请选择班次')
if (selectedEmployeeIds.size === 0) return toast.error('请选择员工')
const items = Array.from(selectedEmployeeIds).map(empId => ({ employeeId: empId, shiftId: selectedShiftId, date }))
batchAssignMutation.mutate(items)
}
const employees = dailyData || []
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
const toggleEmployee = (id: string) => {
const next = new Set(selectedEmployeeIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setSelectedEmployeeIds(next)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<Button onClick={() => setShowAssign(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : employees.length === 0 ? (
<EmptyState title="暂无员工" description="没有可排班的员工" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{employees.map((emp: any) => {
const assignment = assignmentMap.get(emp.employeeId)
return (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3">
{assignment ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
<div className="w-2 h-2 rounded-full" style={{ background: (assignment.shift as any)?.color }} />
{(assignment.shift as any)?.name} {(assignment.shift as any)?.startTime}-{(assignment.shift as any)?.endTime}
</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{assignment && (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
<div className="space-y-3">
<div>
<Label></Label>
<Select value={selectedShiftId} onChange={e => setSelectedShiftId(e.target.value)}>
<option value=""></option>
{(shifts || []).map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.startTime}-{s.endTime})</option>
))}
</Select>
</div>
<div>
<Label>{selectedEmployeeIds.size} </Label>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{employees.map((emp: any) => (
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
<span className="text-sm">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAssign(false)}></Button>
<Button onClick={handleBatchAssign} disabled={batchAssignMutation.isPending}>{batchAssignMutation.isPending ? '排班中...' : '确认排班'}</Button>
</div>
</div>
</Modal>
</div>
)
}
// ========== 每日出勤 Tab ==========
function DailyTab() {
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const { data, isLoading } = useQuery<any>({
queryKey: ['daily-attendance', date],
queryFn: async () => {
const res = await api.get(`/attendance/daily?date=${date}`) as any
return res.data
},
})
const statusColors: Record<string, string> = {
NORMAL: 'bg-green-50 text-green-700',
LATE: 'bg-amber-50 text-amber-700',
EARLY_LEAVE: 'bg-orange-50 text-orange-700',
ABSENT: 'bg-red-50 text-red-700',
LEAVE: 'bg-blue-50 text-blue-700',
BUSINESS_TRIP: 'bg-purple-50 text-purple-700',
UNREGISTERED: 'bg-gray-100 text-gray-500',
}
return (
<div className="space-y-3">
<div className="flex justify-end">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
<EmptyState title="暂无员工" description="没有出勤数据" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left">退</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{data.map((emp: any) => (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime || '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[emp.status] || 'bg-gray-100 text-gray-500'}`}>
{ATTENDANCE_STATUS[emp.status] || emp.status}
</span>
</td>
<td className="px-4 py-3 text-right text-xs">{emp.workHours > 0 ? `${emp.workHours}h` : '—'}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
// ========== 月度报表 Tab ==========
function MonthlyTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const { data, isLoading } = useQuery<any>({
queryKey: ['monthly-report', month],
queryFn: async () => {
const res = await api.get(`/attendance/monthly-report?month=${month}`) as any
return res.data
},
})
const handleExport = () => {
if (!data || data.length === 0) return
const headers = ['姓名', '部门', '出勤天数', '迟到次数', '早退次数', '缺勤天数', '请假天数', '加班工时', '加班费', '确认状态']
const rows = data.map((r: any) => [
r.name, r.department, r.workDays, r.lateCount, r.earlyLeaveCount, r.absentDays, r.leaveDays,
r.overtimeHours, r.overtimePay, r.confirmationStatus === 'CONFIRMED' ? '已确认' : r.confirmationStatus === 'PENDING' ? '待确认' : r.confirmationStatus === 'DISPUTED' ? '有异议' : '未创建',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `attendance-report-${month}.csv`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<Button variant="secondary" onClick={handleExport} disabled={!data || data.length === 0}>
<BarChart3 className="w-4 h-4 mr-1" /> CSV
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
<EmptyState title="暂无报表数据" description="该月份没有出勤数据" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center">退</th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center">(h)</th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((r: any) => (
<tr key={r.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-500">{r.department}</td>
<td className="px-4 py-3 text-center">{r.workDays}</td>
<td className="px-4 py-3 text-center">{r.lateCount > 0 ? <span className="text-amber-600">{r.lateCount}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.earlyLeaveCount > 0 ? <span className="text-orange-600">{r.earlyLeaveCount}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.absentDays > 0 ? <span className="text-red-600">{r.absentDays}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.leaveDays > 0 ? <span className="text-blue-600">{r.leaveDays}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.overtimeHours > 0 ? r.overtimeHours.toFixed(1) : '—'}</td>
<td className="px-4 py-3 text-right">{r.overtimePay > 0 ? `¥${r.overtimePay.toFixed(2)}` : '—'}</td>
<td className="px-4 py-3 text-center">
{r.confirmationStatus === 'CONFIRMED' ? <span className="text-xs text-green-600"></span>
: r.confirmationStatus === 'PENDING' ? <span className="text-xs text-amber-600"></span>
: r.confirmationStatus === 'DISPUTED' ? <span className="text-xs text-red-600"></span>
: <span className="text-xs text-gray-400"></span>}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
// ========== 休假记录 Tab ==========
function LeavesTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
const { data: leaves, isLoading } = useQuery<any>({
queryKey: ['leave-records'],
queryFn: async () => {
const res = await api.get('/attendance/leaves') as any
return res.data
},
})
const { data: rosterData } = useQuery<any>({
queryKey: ['roster-employees'],
queryFn: async () => {
const res = await api.get('/roster?pageSize=200') as any
return res.data
},
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/attendance/leaves', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-records'] })
setShowAdd(false)
setForm({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
toast.success('休假记录已添加')
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/leaves/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['leave-records'] }),
})
const handleSubmit = () => {
if (!form.employeeId) return toast.error('请选择员工')
if (!form.startDate || !form.endDate) return toast.error('请选择日期')
createMutation.mutate(form)
}
const employees = rosterData || []
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !leaves || leaves.length === 0 ? (
<EmptyState title="暂无休假记录" description="点击右上角添加休假记录" />
) : (
<div className="space-y-2">
{leaves.map((lv: any) => (
<Card key={lv.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 flex-shrink-0">
<Plane className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{lv.employee?.name}</span>
<span className="text-xs text-gray-500">{lv.employee?.department}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-700">{LEAVE_TYPES[lv.leaveType] || lv.leaveType}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{lv.startDate?.toString().slice(0, 10)} ~ {lv.endDate?.toString().slice(0, 10)}{lv.days}
{lv.reason && <span className="ml-2">{lv.reason}</span>}
</div>
</div>
</div>
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(lv.id) }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</Card>
))}
</div>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title="新增休假记录">
<div className="space-y-3">
<div>
<Label></Label>
<Select value={form.employeeId} onChange={e => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Select value={form.leaveType} onChange={e => setForm({ ...form, leaveType: e.target.value })}>
{Object.entries(LEAVE_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input type="number" step="0.5" value={form.days} onChange={e => setForm({ ...form, days: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={e => setForm({ ...form, reason: e.target.value })} placeholder="请简述请假原因" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAdd(false)}></Button>
<Button onClick={handleSubmit} disabled={createMutation.isPending}>{createMutation.isPending ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
</div>
)
}
+391
View File
@@ -0,0 +1,391 @@
import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, MapPin, User } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
const EVENT_TYPE_COLORS: Record<string, string> = {
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
PROBATION_END: 'bg-amber-100 text-amber-700 border-amber-200',
TERMINATION: 'bg-red-100 text-red-700 border-red-200',
ANNIVERSARY: 'bg-green-100 text-green-700 border-green-200',
RISK_DEADLINE: 'bg-orange-100 text-orange-700 border-orange-200',
RETIREMENT: 'bg-purple-100 text-purple-700 border-purple-200',
CUSTOM: 'bg-blue-100 text-blue-700 border-blue-200',
MEETING: 'bg-cyan-100 text-cyan-700 border-cyan-200',
TEAM_BUILDING: 'bg-pink-100 text-pink-700 border-pink-200',
TRAINING: 'bg-indigo-100 text-indigo-700 border-indigo-200',
INTERVIEW: 'bg-teal-100 text-teal-700 border-teal-200',
}
const EVENT_TYPE_LABELS: Record<string, string> = {
CONTRACT_EXPIRY: '合同到期',
PROBATION_END: '试用期到期',
TERMINATION: '离职/解聘',
ANNIVERSARY: '入职周年',
RISK_DEADLINE: '风险截止',
RETIREMENT: '退休',
CUSTOM: '自定义',
MEETING: '会议',
TEAM_BUILDING: '团建',
TRAINING: '培训',
INTERVIEW: '面试',
}
const PRIORITY_DOT: Record<string, string> = {
high: 'bg-red-500',
medium: 'bg-amber-500',
low: 'bg-gray-400',
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
export default function Calendar() {
const queryClient = useQueryClient()
const [calendarMonth, setCalendarMonth] = useState(new Date().toISOString().slice(0, 7))
const [showEventForm, setShowEventForm] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<string | null>(null)
const [eventForm, setEventForm] = useState({
title: '',
date: new Date().toISOString().slice(0, 10),
type: 'CUSTOM',
priority: 'medium',
location: '',
description: '',
})
const { data: calendarData } = useQuery<any>({
queryKey: ['calendar', calendarMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/calendar?month=${calendarMonth}`) as any
return res.data
},
})
const { data: customEvents } = useQuery<any[]>({
queryKey: ['custom-events', calendarMonth],
queryFn: async () => {
const res = await api.get(`/calendar?month=${calendarMonth}`) as any
return res.data
},
})
const createEventMutation = useMutation({
mutationFn: (data: any) => api.post('/calendar', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
queryClient.invalidateQueries({ queryKey: ['calendar'] })
setShowEventForm(false)
setEventForm({ title: '', date: selectedDate || new Date().toISOString().slice(0, 10), type: 'CUSTOM', priority: 'medium', location: '', description: '' })
toast.success('事件已创建')
},
onError: () => toast.error('创建事件失败'),
})
const deleteEventMutation = useMutation({
mutationFn: (id: string) => api.delete(`/calendar/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
queryClient.invalidateQueries({ queryKey: ['calendar'] })
toast.success('事件已删除')
},
})
const calendarGrid = useMemo(() => {
const [year, mon] = calendarMonth.split('-').map(Number)
const firstDay = new Date(year, mon - 1, 1)
const lastDay = new Date(year, mon, 0)
const startWeekday = firstDay.getDay()
const daysInMonth = lastDay.getDate()
const todayStr = new Date().toISOString().slice(0, 10)
const cells: Array<{ day: number | null; date: string | null; events: any[]; isToday: boolean }> = []
for (let i = 0; i < startWeekday; i++) cells.push({ day: null, date: null, events: [], isToday: false })
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${calendarMonth}-${String(d).padStart(2, '0')}`
let dayEvents = (calendarData?.events || []).filter((ev: any) => ev.date === dateStr)
if (typeFilter) dayEvents = dayEvents.filter((ev: any) => ev.type === typeFilter)
cells.push({ day: d, date: dateStr, events: dayEvents, isToday: dateStr === todayStr })
}
return cells
}, [calendarMonth, calendarData, typeFilter])
const allEvents = useMemo(() => {
let events = calendarData?.events || []
if (typeFilter) events = events.filter((ev: any) => ev.type === typeFilter)
return events
}, [calendarData, typeFilter])
const customEventMap = useMemo(() => {
const map: Record<string, any> = {}
for (const ev of (customEvents || [])) {
map[ev.id] = ev
}
return map
}, [customEvents])
const prevMonth = () => {
const [y, m] = calendarMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
setCalendarMonth(d.toISOString().slice(0, 7))
}
const nextMonth = () => {
const [y, m] = calendarMonth.split('-').map(Number)
const d = new Date(y, m, 1)
setCalendarMonth(d.toISOString().slice(0, 7))
}
const goToday = () => setCalendarMonth(new Date().toISOString().slice(0, 7))
const handleDayClick = (date: string | null) => {
if (!date) return
setSelectedDate(date)
setEventForm({ ...eventForm, date })
setShowEventForm(true)
}
const handleSubmitEvent = () => {
if (!eventForm.title.trim()) {
toast.error('请输入事件标题')
return
}
createEventMutation.mutate(eventForm)
}
const isCustomEvent = (ev: any) => {
return !!customEventMap[ev.id] || ['CUSTOM', 'MEETING', 'TEAM_BUILDING', 'TRAINING', 'INTERVIEW'].includes(ev.type)
}
return (
<div className="space-y-3">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold flex items-center gap-2">
<CalendarDays className="w-5 h-5 text-primary" />
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={prevMonth}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium min-w-[80px] text-center">{calendarMonth}</span>
<Button variant="secondary" size="sm" onClick={nextMonth}>
<ChevronRight className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={goToday}></Button>
<Button size="sm" onClick={() => { setEventForm({ ...eventForm, date: new Date().toISOString().slice(0, 10) }); setShowEventForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 类型筛选 */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setTypeFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${!typeFilter ? 'bg-gray-700 text-white border-gray-700' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}
>
</button>
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
<button
key={type}
onClick={() => setTypeFilter(typeFilter === type ? null : type)}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${typeFilter === type ? 'bg-gray-700 text-white border-gray-700' : EVENT_TYPE_COLORS[type] || 'bg-gray-100 text-gray-600 border-gray-200'}`}
>
{label}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{/* 月历网格 */}
<div className="lg:col-span-2">
<Card>
<div className="grid grid-cols-7 gap-px mb-1">
{WEEKDAYS.map(wd => (
<div key={wd} className="text-center text-xs font-medium text-gray-400 py-1.5">{wd}</div>
))}
</div>
<div className="grid grid-cols-7 gap-px">
{calendarGrid.map((cell, i) => (
<div
key={i}
onClick={() => handleDayClick(cell.date)}
className={`min-h-[72px] p-1.5 rounded-md cursor-pointer transition-colors border ${
cell.day === null
? 'bg-gray-50/50 border-transparent cursor-default'
: cell.isToday
? 'bg-primary/5 border-primary/30 hover:bg-primary/10'
: 'border-gray-100 hover:bg-gray-50'
}`}
>
{cell.day && (
<>
<div className={`text-xs font-medium mb-0.5 ${cell.isToday ? 'text-primary' : 'text-gray-600'}`}>
{cell.day}
</div>
<div className="space-y-0.5">
{cell.events.slice(0, 3).map((ev: any, idx: number) => (
<div
key={idx}
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
</div>
))}
{cell.events.length > 3 && (
<div className="text-[10px] text-gray-400 px-1">+{cell.events.length - 3} </div>
)}
</div>
</>
)}
</div>
))}
</div>
</Card>
</div>
{/* 事件列表 */}
<div>
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<CalendarDays className="w-4 h-4 text-primary" />
{calendarMonth}
<span className="text-xs text-gray-400 font-normal">({allEvents.length})</span>
</h3>
{allEvents.length > 0 ? (
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
{allEvents.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-2 rounded-md hover:bg-gray-50 group">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 flex-shrink-0">{ev.date.slice(5)}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
{EVENT_TYPE_LABELS[ev.type] || ev.type}
</span>
</div>
<div className="text-xs text-gray-800 mt-0.5 truncate">
{ev.title}
{ev.employeeName && <span className="text-gray-400 ml-1"> {ev.employeeName}</span>}
</div>
{ev.actionUrl && ev.actionUrl !== '/dashboard' && (
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
)}
</div>
{isCustomEvent(ev) && customEventMap[ev.id] && (
<button
onClick={() => deleteEventMutation.mutate(ev.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-8"></div>
)}
</Card>
</div>
</div>
{/* 新建事件弹窗 */}
{showEventForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowEventForm(false)}>
<Card className="w-full max-w-md mx-4" onClick={(e: any) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium flex items-center gap-1.5">
<Plus className="w-4 h-4 text-primary" />
</h3>
<button onClick={() => setShowEventForm(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input
value={eventForm.title}
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
placeholder="如:月度全员会议"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input
type="date"
value={eventForm.date}
onChange={(e) => setEventForm({ ...eventForm, date: e.target.value })}
/>
</div>
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
value={eventForm.type}
onChange={(e) => setEventForm({ ...eventForm, type: e.target.value })}
>
<option value="CUSTOM"></option>
<option value="MEETING"></option>
<option value="TEAM_BUILDING"></option>
<option value="TRAINING"></option>
<option value="INTERVIEW"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
value={eventForm.priority}
onChange={(e) => setEventForm({ ...eventForm, priority: e.target.value })}
>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</div>
<div>
<Label></Label>
<Input
value={eventForm.location}
onChange={(e) => setEventForm({ ...eventForm, location: e.target.value })}
placeholder="可选"
/>
</div>
</div>
<div>
<Label></Label>
<Input
value={eventForm.description}
onChange={(e) => setEventForm({ ...eventForm, description: e.target.value })}
placeholder="可选"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowEventForm(false)}></Button>
<Button size="sm" onClick={handleSubmitEvent} disabled={createEventMutation.isPending}>
{createEventMutation.isPending ? '创建中...' : '创建'}
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
+42 -5
View File
@@ -34,15 +34,20 @@ interface EmployeeListResponse {
export default function Contracts() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [page, setPage] = useState(1)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
const { data, isLoading } = useQuery<EmployeeListResponse>({
queryKey: ['employees', search, page],
queryKey: ['employees', search, filterDepartment, filterContractStatus, page],
queryFn: async () => {
const res = await api.get('/employees', { params: { search, page, pageSize: 20 } }) as any
return res.data
const params: any = { search, page, pageSize: 20 }
if (filterDepartment) params.department = filterDepartment
if (filterContractStatus) params.contractStatus = filterContractStatus
const res = await api.get('/roster', { params }) as any
return res
},
})
@@ -57,6 +62,14 @@ export default function Contracts() {
},
})
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -73,8 +86,8 @@ export default function Contracts() {
</div>
{/* 搜索栏 */}
<div className="flex gap-2">
<div className="relative flex-1">
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="搜索员工姓名或手机号"
@@ -83,6 +96,30 @@ export default function Contracts() {
className="pl-9"
/>
</div>
<select
value={filterDepartment}
onChange={(e) => { setFilterDepartment(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<select
value={filterContractStatus}
onChange={(e) => { setFilterContractStatus(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
<option value="active"></option>
<option value="expiring"></option>
<option value="expired"></option>
<option value="unsigned"></option>
<option value="unsigned_over_30">(30)</option>
<option value="unsigned_over_year">()</option>
</select>
{(search || filterDepartment || filterContractStatus) && (
<button onClick={() => { setSearch(''); setFilterDepartment(''); setFilterContractStatus(''); setPage(1) }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
</div>
{/* 员工列表 */}
+154 -40
View File
@@ -3,8 +3,9 @@ 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, CalendarDays, 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 } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
@@ -57,13 +58,6 @@ export default function Dashboard() {
})
const currentMonth = new Date().toISOString().slice(0, 7)
const { data: calendarData } = useQuery<any>({
queryKey: ['calendar', currentMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/calendar?month=${currentMonth}`) as any
return res.data
},
})
const { data: costAnalysis } = useQuery<any>({
queryKey: ['cost-analysis', currentMonth],
@@ -81,6 +75,14 @@ export default function Dashboard() {
},
})
const { data: workforceStats } = useQuery<any>({
queryKey: ['workforce-stats'],
queryFn: async () => {
const res = await api.get('/dashboard/workforce-stats') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
@@ -107,9 +109,21 @@ export default function Dashboard() {
},
})
const handleExportPayroll = () => {
const month = payroll?.month || new Date().toISOString().slice(0, 7)
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
const handleExportPayroll = async () => {
try {
const month = payroll?.month || new Date().toISOString().slice(0, 7)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/payroll?month=${month}`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `薪税汇总-${month}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}
const toggleSelect = (id: string) => {
@@ -583,35 +597,8 @@ export default function Dashboard() {
</Card>
</div>
{/* HR 月度日历 + 人力成本分析 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* 月度日历 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><CalendarDays className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{currentMonth}</span>
</div>
{calendarData?.events && calendarData.events.length > 0 ? (
<div className="space-y-1.5 max-h-64 overflow-y-auto">
{calendarData.events.slice(0, 10).map((ev: any, i: number) => (
<Link key={i} to={ev.actionUrl || '/'} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
ev.priority === 'high' ? 'bg-red-500' : ev.priority === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
<span className="text-gray-500 w-20 flex-shrink-0">{ev.date.slice(5)}</span>
<span className="text-gray-800 truncate flex-1">{ev.title}</span>
</Link>
))}
{calendarData.events.length > 10 && (
<div className="text-xs text-gray-500 text-center pt-1"> {calendarData.events.length - 10} </div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
)}
</Card>
{/* 人力成本分析 */}
{/* 人力成本分析 */}
<div className="grid grid-cols-1 gap-3">
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
@@ -654,6 +641,36 @@ export default function Dashboard() {
))}
</div>
)}
{costAnalysis.departmentCost && costAnalysis.departmentCost.length > 0 && (
<div className="space-y-1.5 border-t pt-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="max-h-40 overflow-y-auto space-y-1">
{costAnalysis.departmentCost.map((d: any, i: number) => {
const maxCost = costAnalysis.departmentCost[0].totalCost || 1
return (
<div key={i} className="text-xs">
<div className="flex items-center justify-between mb-0.5">
<span className="text-gray-700">{d.department}{d.headcount}</span>
<span className="font-medium text-gray-800">{fmt(d.totalCost)}</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary/60 rounded-full"
style={{ width: `${(d.totalCost / maxCost) * 100}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
<span> {fmt(d.totalPay)}</span>
<span> {fmt(d.socialOrg)}</span>
<span> {fmt(d.housingOrg)}</span>
<span> {fmt(d.perCapita)}</span>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
@@ -767,6 +784,103 @@ export default function Dashboard() {
</Card>
)}
{/* 员工分布统计 */}
{activeTab === 'overview' && workforceStats && workforceStats.total > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* 性别分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Users className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.gender} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.gender.map((_: any, i: number) => <Cell key={i} fill={['#3b82f6', '#ec4899', '#9ca3af'][i % 3]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-2 text-xs mt-1">
{workforceStats.gender.map((g: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#3b82f6', '#ec4899', '#9ca3af'][i % 3] }} />
{g.name} {g.value}
</span>
))}
</div>
</Card>
{/* 年龄段分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Users className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.age.filter((a: any) => a.value > 0)} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.age.filter((a: any) => a.value > 0).map((_: any, i: number) => <Cell key={i} fill={['#22c55e', '#10b981', '#3b82f6', '#6366f1', '#f59e0b', '#ef4444'][i % 6]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.age.filter((a: any) => a.value > 0).map((a: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#22c55e', '#10b981', '#3b82f6', '#6366f1', '#f59e0b', '#ef4444'][i % 6] }} />
{a.name} {a.value}
</span>
))}
</div>
</Card>
{/* 学历分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><BookOpen className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.education} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.education.map((_: any, i: number) => <Cell key={i} fill={['#8b5cf6', '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#9ca3af'][i % 7]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.education.map((e: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#8b5cf6', '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#9ca3af'][i % 7] }} />
{e.name} {e.value}
</span>
))}
</div>
</Card>
{/* 司龄分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Clock className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.tenure.filter((t: any) => t.value > 0)} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.tenure.filter((t: any) => t.value > 0).map((_: any, i: number) => <Cell key={i} fill={['#a5f3fc', '#67e8f9', '#22d3ee', '#0891b2', '#155e75'][i % 5]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.tenure.filter((t: any) => t.value > 0).map((t: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#a5f3fc', '#67e8f9', '#22d3ee', '#0891b2', '#155e75'][i % 5] }} />
{t.name} {t.value}
</span>
))}
</div>
</Card>
</div>
)}
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-3">
+99 -6
View File
@@ -654,7 +654,24 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<Button
variant="secondary"
size="sm"
onClick={() => window.open('/api/v1/import/payroll-template', '_blank')}
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/payroll-template`, {
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 = '工资表导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载模板失败')
}
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
@@ -696,11 +713,87 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)}
{isArchived && (
<div className="flex gap-2">
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Download className="w-4 h-4 mr-1" />
</Button>
</a>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/payroll2/batches/${batchId}/export?format=csv`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `银行代发文件-${batch.month}-批次${batch.batchNo}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await api.get(`/payroll/batch/${batchId}/summary`) as any
const { departments, grandTotal } = res.data
const headers = ['部门', '人数', '应发合计', '实发合计', '个人社保', '单位社保', '个人公积金', '单位公积金', '个税合计']
const rows = departments.map((d: any) => [
d.department, d.headcount, d.totalPay.toFixed(2), d.totalNetPay.toFixed(2),
d.totalSocialEmp.toFixed(2), d.totalSocialOrg.toFixed(2),
d.totalHousingEmp.toFixed(2), d.totalHousingOrg.toFixed(2), d.totalTax.toFixed(2),
])
rows.push(['合计', grandTotal.headcount, grandTotal.totalPay.toFixed(2), grandTotal.totalNetPay.toFixed(2),
grandTotal.totalSocialEmp.toFixed(2), grandTotal.totalSocialOrg.toFixed(2),
grandTotal.totalHousingEmp.toFixed(2), grandTotal.totalHousingOrg.toFixed(2), grandTotal.totalTax.toFixed(2)])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `salary-summary-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出汇总表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await api.get(`/payroll/batch/${batchId}/detail`) as any
const { details } = res.data
const headers = ['姓名', '部门', '基本工资', '岗位工资', '绩效工资', '工龄工资', '加班费', '交通补贴', '餐补', '住房补贴', '通讯补贴', '其他津贴', '奖金', '扣款', '其他扣款', '个人社保', '个人公积金', '个税', '应发合计', '实发工资']
const rows = details.map((d: any) => [
d.name, d.department,
d.baseSalary.toFixed(2), d.positionSalary.toFixed(2), d.performanceSalary.toFixed(2),
d.senioritySalary.toFixed(2), d.overtimePay.toFixed(2),
d.transportAllowance.toFixed(2), d.mealAllowance.toFixed(2), d.housingAllowance.toFixed(2),
d.communicationAllowance.toFixed(2), d.allowance.toFixed(2), d.bonus.toFixed(2),
d.deduction.toFixed(2), d.otherDeduction.toFixed(2),
d.socialEmp.toFixed(2), d.housingEmp.toFixed(2), d.tax.toFixed(2),
d.totalPay.toFixed(2), d.netPay.toFixed(2),
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `salary-detail-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出明细表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
+48 -4
View File
@@ -2,8 +2,9 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet } from 'lucide-react'
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet, Download } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -39,18 +40,20 @@ export default function Roster() {
const [previewData, setPreviewData] = useState<any>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
const { data: rosterData, isLoading } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus],
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
queryFn: async () => {
const params: any = { page, pageSize }
if (debouncedSearch) params.search = debouncedSearch
if (filterStatus) params.status = filterStatus
if (filterContractStatus) params.contractStatus = filterContractStatus
if (filterDepartment) params.department = filterDepartment
const res = await api.get('/roster', { params }) as any
return res
},
@@ -206,10 +209,19 @@ export default function Roster() {
setSearch('')
setFilterStatus('')
setFilterContractStatus('')
setFilterDepartment('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
@@ -231,7 +243,7 @@ export default function Roster() {
</div>
<div className="flex flex-wrap items-center justify-start gap-2 xl:justify-end">
<Input
placeholder="搜索姓名部门"
placeholder="搜索姓名部门或身份证后4位"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="!w-full sm:!w-64 shrink-0"
@@ -259,6 +271,14 @@ export default function Roster() {
<option value="unsigned_over_30">(30)</option>
<option value="unsigned_over_year">()</option>
</select>
<select
value={filterDepartment}
onChange={(e) => { setFilterDepartment(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{hasActiveFilters && (
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700"></button>
)}
@@ -268,6 +288,28 @@ export default function Roster() {
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={async () => {
try {
const params = new URLSearchParams()
if (debouncedSearch) params.set('search', debouncedSearch)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/roster?${params}`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `花名册-${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}} className="h-9 shrink-0">
<Download className="mr-1.5 h-4 w-4" />
</Button>
</div>
</div>
@@ -308,6 +350,7 @@ export default function Roster() {
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
@@ -335,6 +378,7 @@ export default function Roster() {
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="px-4 py-3 font-medium">{e.name}</td>
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{e.idCardMasked || '—'}</td>
<td className="px-4 py-3 text-gray-500">{e.department}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
+6 -4
View File
@@ -1012,14 +1012,15 @@ function InitImport() {
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/template', {
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
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 = 'import-template.xlsx'
a.download = '员工导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
@@ -1193,14 +1194,15 @@ function MonthlyImport() {
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/monthly-template', {
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/monthly-template`, {
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 = 'monthly-import-template.xlsx'
a.download = '月度增减员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
+42 -10
View File
@@ -43,6 +43,7 @@ export default function SocialInsurance() {
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
accountType: 'BASIC',
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
@@ -71,6 +72,14 @@ export default function SocialInsurance() {
return res.data
},
})
const { data: housingAllAccounts } = useQuery<any[]>({
queryKey: ['housing-config-all-accounts', city],
queryFn: async () => {
const res = await api.get('/social/housing-config/versions', { params: { city } }) as any
const current = (res.data || []).filter((v: any) => v.isCurrent)
return current
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions', city],
@@ -471,12 +480,24 @@ export default function SocialInsurance() {
</div>
</div>
{isHousing ? (
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div>
<>
{(housingAllAccounts || []).length > 1 && (
<div className="flex gap-2 mb-3">
{(housingAllAccounts || []).map((a: any) => (
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
</span>
))}
</div>
)}
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div>
</>
) : (
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
@@ -611,6 +632,7 @@ export default function SocialInsurance() {
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
{isHousing && <th className="py-2 text-left"></th>}
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
{isHousing ? (
@@ -630,6 +652,7 @@ export default function SocialInsurance() {
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</td>}
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
{isHousing ? (
@@ -695,10 +718,19 @@ export default function SocialInsurance() {
</div>
)}
{isHousing ? (
<div className="grid md:grid-cols-2 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
</div>
<>
<div className="grid md:grid-cols-3 gap-3">
<div>
<Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.accountType || 'BASIC'} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, accountType: e.target.value })}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</select>
</div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
</div>
</>
) : (
<div className="grid md:grid-cols-4 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
+73 -2
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
import jsPDF from 'jspdf'
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'
@@ -115,6 +116,9 @@ export default function Termination() {
}>>([])
// 是否展开对比
const [showCompare, setShowCompare] = useState(false)
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const { data: employees } = useQuery<RosterEmployee[]>({
queryKey: ['roster-for-termination'],
@@ -126,6 +130,14 @@ export default function Termination() {
const selectedEmployee = employees?.find((e) => e.id === employeeId)
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
const { data: profile } = useQuery<EmployeeProfile>({
queryKey: ['employee-profile', employeeId],
queryFn: async () => {
@@ -239,9 +251,13 @@ export default function Termination() {
// 草稿列表
const { data: drafts, refetch: refetchDrafts } = useQuery({
queryKey: ['termination-drafts'],
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm],
queryFn: async () => {
const res = await api.get('/termination/drafts') as any
const params: any = {}
if (filterStatus) params.status = filterStatus
if (filterDepartment) params.department = filterDepartment
if (searchTerm) params.search = searchTerm
const res = await api.get('/termination/drafts', { params }) as any
return res.data
},
enabled: view === 'list',
@@ -624,6 +640,60 @@ export default function Termination() {
{/* 草稿列表视图 */}
{view === 'list' && (
<>
<div className="flex gap-2 flex-wrap items-center">
<Input
placeholder="搜索员工姓名或部门"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="!w-48"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
<option value="DRAFT">稿</option>
<option value="PENDING_APPROVAL"></option>
<option value="APPROVED"></option>
<option value="EXECUTING"></option>
<option value="COMPLETED"></option>
<option value="CANCELLED"></option>
</select>
<select
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{(searchTerm || filterStatus || filterDepartment) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment('') }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
const params = new URLSearchParams()
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/terminations?${params}`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `解聘记录-${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
<Card>
{(!drafts || drafts.length === 0) ? (
<EmptyState
@@ -754,6 +824,7 @@ export default function Termination() {
</div>
)}
</Card>
</>
)}
{/* 详情视图 */}
+4
View File
@@ -76,6 +76,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
housingFundBase: profile.housingFundBase ?? '',
specialDeduction: profile.specialDeduction ?? 0,
city: profile.city || '',
education: profile.education || '',
cityChangeReason: '',
})
@@ -112,6 +113,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
education: form.education || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
@@ -126,6 +128,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
: []),
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
@@ -221,6 +224,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
<div><Label></Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
+3 -1
View File
@@ -517,7 +517,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
@@ -619,6 +619,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
idCardNumber: form.idCardNumber || undefined,
phone: form.phone || undefined,
education: form.education || undefined,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
@@ -669,6 +670,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
</div>
{/* 社保公积金 */}
<div className="border-t border-gray-200 pt-4">