d79e3baa34
- 面包屑导航组件,集成至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
237 lines
15 KiB
TypeScript
237 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|