feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
@@ -0,0 +1,236 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 考勤/加班/培训合并组件 */
export default function AttendanceOvertimeInfo({ employeeId, attendanceRecords, overtimeRecords, trainingRecords }: { employeeId: string; attendanceRecords: any[]; overtimeRecords: any[]; trainingRecords: any[] }) {
const queryClient = useQueryClient()
const [subTab, setSubTab] = useState<'attendance' | 'overtime' | 'training'>('attendance')
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, remark: '' })
const [trainingForm, setTrainingForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/attendance`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/attendance/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const createTrainingMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteTrainingMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/training/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const statusMap: Record<string, string> = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
const statusColor: Record<string, string> = { NORMAL: 'bg-green-50 text-safe', LATE: 'bg-amber-50 text-warning', EARLY_LEAVE: 'bg-amber-50 text-warning', ABSENT: 'bg-red-50 text-danger', LEAVE: 'bg-blue-50 text-blue-600', BUSINESS_TRIP: 'bg-blue-50 text-blue-600' }
const totalPay = (overtimeRecords || []).reduce((sum, o) => sum + (o.totalPay || 0), 0)
const totalWeekday = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekdayHours || 0), 0)
const totalWeekend = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekendHours || 0), 0)
const totalHoliday = (overtimeRecords || []).reduce((sum, o) => sum + (o.holidayHours || 0), 0)
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex gap-1">
<button
onClick={() => setSubTab('attendance')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'attendance' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{attendanceRecords?.length || 0}
</button>
<button
onClick={() => setSubTab('overtime')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'overtime' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{overtimeRecords?.length || 0}
</button>
<button
onClick={() => setSubTab('training')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'training' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{trainingRecords?.length || 0}
</button>
</div>
{subTab === 'attendance' && (
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
)}
{subTab === 'training' && (
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
)}
</div>
{subTab === 'attendance' && (
<>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
<div><Label></Label>
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input type="time" value={form.checkInTime} onChange={(e) => setForm({ ...form, checkInTime: e.target.value })} /></div>
<div><Label>退</Label><Input type="time" value={form.checkOutTime} onChange={(e) => setForm({ ...form, checkOutTime: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.lateMinutes} onChange={(e) => setForm({ ...form, lateMinutes: Number(e.target.value) })} /></div>
<div><Label>退</Label><Input type="number" value={form.earlyMinutes} onChange={(e) => setForm({ ...form, earlyMinutes: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.workHours} onChange={(e) => setForm({ ...form, workHours: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.overtimeHours} onChange={(e) => setForm({ ...form, overtimeHours: Number(e.target.value) })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.date}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{attendanceRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">退</th>
<th className="py-2 text-center"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{attendanceRecords?.map((a) => (
<tr key={a.id} className="border-b last:border-0">
<td className="py-2">{a.date?.toString().slice(0, 10)}</td>
<td className="py-2 text-gray-500">{a.checkInTime || '-'}</td>
<td className="py-2 text-gray-500">{a.checkOutTime || '-'}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${statusColor[a.status] || 'bg-gray-100'}`}>{statusMap[a.status] || a.status}</span></td>
<td className="py-2 text-right">{a.workHours}h</td>
<td className="py-2 text-right">{a.overtimeHours > 0 ? `${a.overtimeHours}h` : '-'}</td>
<td className="py-2"><button onClick={() => deleteMutation.mutate(a.id)} className="text-xs text-gray-400 hover:text-danger"></button></td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'overtime' && (
<>
{overtimeRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{overtimeRecords.map((o) => (
<tr key={o.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{o.month}</td>
<td className="py-2 text-right text-gray-600">{o.weekdayHours || '-'}</td>
<td className="py-2 text-right text-gray-600">{o.weekendHours || '-'}</td>
<td className="py-2 text-right text-gray-600">{o.holidayHours || '-'}</td>
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(o.totalPay)}</td>
</tr>
))}
<tr className="border-t-2 bg-gray-50">
<td className="py-2 font-medium"></td>
<td className="py-2 text-right font-medium text-gray-600">{totalWeekday}</td>
<td className="py-2 text-right font-medium text-gray-600">{totalWeekend}</td>
<td className="py-2 text-right font-medium text-gray-600">{totalHoliday}</td>
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(totalPay)}</td>
</tr>
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'training' && (
<>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={trainingForm.trainingDate} onChange={(e) => setTrainingForm({ ...trainingForm, trainingDate: e.target.value })} /></div>
<div><Label>/</Label><Input value={trainingForm.topic} onChange={(e) => setTrainingForm({ ...trainingForm, topic: e.target.value })} placeholder="如《员工手册》培训" /></div>
<div className="md:col-span-2"><Label></Label><Input value={trainingForm.content} onChange={(e) => setTrainingForm({ ...trainingForm, content: e.target.value })} /></div>
<div><Label></Label><Input value={trainingForm.trainer} onChange={(e) => setTrainingForm({ ...trainingForm, trainer: e.target.value })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={trainingForm.duration} onChange={(e) => setTrainingForm({ ...trainingForm, duration: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={trainingForm.ackStatus} onChange={(e) => setTrainingForm({ ...trainingForm, ackStatus: e.target.value })}>
{Object.entries(ackMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
{trainingForm.ackStatus === 'SIGNED' && <div><Label></Label><Input type="date" value={trainingForm.ackDate} onChange={(e) => setTrainingForm({ ...trainingForm, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2"><Label></Label><Input value={trainingForm.remark} onChange={(e) => setTrainingForm({ ...trainingForm, remark: e.target.value })} /></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createTrainingMutation.mutate(trainingForm)} disabled={createTrainingMutation.isPending || !trainingForm.trainingDate || !trainingForm.topic}>{createTrainingMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{trainingRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : trainingRecords?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400">{r.trainingDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.ackStatus === 'SIGNED' ? 'bg-green-50 text-safe' : r.ackStatus === 'REFUSED' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-600'}`}>
{r.ackStatus === 'SIGNED' ? '已签收' : r.ackStatus === 'REFUSED' ? '拒绝签收' : '待签收'}
</span>
</div>
<div className="text-xs font-medium text-gray-700">{r.topic}</div>
{r.content && <div className="text-xs text-gray-500 leading-relaxed">{r.content}</div>}
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
<span> {r.duration}h</span>
{r.trainer && <span>{r.trainer}</span>}
{r.ackDate && <span className="text-safe">{r.ackDate.toString().slice(0, 10)}</span>}
{r.remark && <span className="text-gray-400">{r.remark}</span>}
</div>
</div>
<button onClick={() => deleteTrainingMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</>
)}
</div>
)
}