feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 员工列表 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 详情视图 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user