'use client'; import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Search, Plus, X, Download, FolderOpen, Building2 } from 'lucide-react'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { flattenProjects, flattenVersions } from '@/lib/derive'; import { calcDuration, filterOvertimeRecordsForViewer } from '@/lib/overtime'; import type { OvertimeRecord } from '@/lib/overtime'; import { Pagination, usePagination } from '@/components/Pagination'; import { DictDrawer } from '@/components/requirement/DictDrawer'; import { MonthPicker } from '@/components/MonthPicker'; import { FilterSelect } from '@/components/FilterSelect'; import { FieldError } from '@/components/FieldError'; import { RouteGuard } from '@/components/auth/Guard'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import { isMemberReference } from '@/lib/member-system'; import type { Department } from '@/lib/members'; export default function OvertimePage() { return ( ); } function OvertimePageContent() { const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore(); const { overview, fetchOverview } = useProductStore(); const { requirements, fetchRequirements } = useRequirementStore(); const { departments, members, roles, fetchMembers } = useMemberStore(); const user = useAuthStore((s) => s.user); const viewerRole = useMemo(() => roles.find((role) => role.id === user?.roleId), [roles, user?.roleId]); const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); const [search, setSearch] = useState(''); const [projectFilter, setProjectFilter] = useState('all'); const [reasonFilter, setReasonFilter] = useState('all'); const [monthFilter, setMonthFilter] = useState(''); const [departmentFilter, setDepartmentFilter] = useState('all'); const [showModal, setShowModal] = useState(false); const [showReasonDrawer, setShowReasonDrawer] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRecords(); }, [fetchRecords]); useEffect(() => { fetchMembers(); }, [fetchMembers]); const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-'; const versionName = (id?: string) => id ? (allVersions.find((v) => v.id === id)?.name ?? '-') : '-'; const reasonName = (id: string) => reasons.find((r) => r.id === id)?.name ?? '-'; const visibleRecords = useMemo(() => filterOvertimeRecordsForViewer(records, { viewer: user, viewerRole, members, departments, }), [records, user, viewerRole, members, departments]); const departmentRows = useMemo(() => buildDepartmentRows(departments), [departments]); const getRecordDepartmentId = useMemo(() => { return (record: OvertimeRecord) => { const member = members.find((item) => isMemberReference(record.person, item)); if (member) return member.departmentId; if (user && isMemberReference(record.person, user)) return user.departmentId; return 'unknown'; }; }, [members, user]); const baseFiltered = useMemo(() => { let list = [...visibleRecords]; if (search) list = list.filter((r) => r.person.includes(search)); if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter); if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter); if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter); list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()); return list; }, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]); const departmentStats = useMemo(() => { const map = new Map(); for (const dept of departments) map.set(dept.id, { count: 0, hours: 0 }); map.set('unknown', { count: 0, hours: 0 }); for (const record of baseFiltered) { const deptId = getRecordDepartmentId(record); const current = map.get(deptId) ?? { count: 0, hours: 0 }; current.count += 1; current.hours += record.duration; map.set(deptId, current); } for (const dept of departments) { const childIds = collectDepartmentTreeIds(departments, dept.id); const total = { count: 0, hours: 0 }; for (const id of childIds) { const stat = map.get(id); if (!stat) continue; total.count += stat.count; total.hours += stat.hours; } map.set(`${dept.id}:tree`, total); } return map; }, [baseFiltered, departments, getRecordDepartmentId]); const filtered = useMemo(() => { if (departmentFilter === 'all') return baseFiltered; if (departmentFilter === 'unknown') { return baseFiltered.filter((record) => getRecordDepartmentId(record) === 'unknown'); } const deptIds = collectDepartmentTreeIds(departments, departmentFilter); return baseFiltered.filter((record) => deptIds.has(getRecordDepartmentId(record))); }, [baseFiltered, departmentFilter, departments, getRecordDepartmentId]); const selectedDepartmentName = departmentFilter === 'all' ? '全部部门' : departmentFilter === 'unknown' ? '未匹配部门' : departments.find((dept) => dept.id === departmentFilter)?.name ?? '部门'; const totalBaseHours = Math.round(baseFiltered.reduce((sum, record) => sum + record.duration, 0) * 10) / 10; const selectedHours = Math.round(filtered.reduce((sum, record) => sum + record.duration, 0) * 10) / 10; const unknownStats = departmentStats.get('unknown') ?? { count: 0, hours: 0 }; const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); const handleCreate = () => { setShowModal(true); }; const handleExport = () => { const header = ['项目', '版本', '加班人', '开始时间', '结束时间', '时长(h)', '加班原因', '备注']; const rows = filtered.map((r) => [ projectName(r.projectId), versionName(r.versionId), r.person, r.startTime.replace('T', ' '), r.endTime.replace('T', ' '), String(r.duration), reasonName(r.reasonId), r.remark || '', ]); const bom = ''; const csv = [header.join(','), ...rows.map((row) => row.map((c) => `"${c.replace(/"/g, '""')}"`).join(','))].join('\r\n'); const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `加班记录${monthFilter || '_全部'}.csv`; a.click(); URL.revokeObjectURL(url); }; return (

加班记录

{visibleRecords.length}
{/* Filters */}
setSearch(e.target.value)} placeholder="搜索人员" className="h-8 w-48 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none" />
({ value: p.id, label: p.name }))} placeholder="全部项目" allLabel="全部项目" /> ({ value: r.id, label: r.name }))} placeholder="全部原因" allLabel="全部原因" />

{selectedDepartmentName}

{filtered.length}

合计 {selectedHours}h

{filtered.length === 0 ? (

暂无加班记录

) : ( <>
{paged.map((r) => ( ))}
项目 版本 加班人 开始时间 结束时间 时长 加班原因 备注 创建日期 操作
{projectName(r.projectId)} {versionName(r.versionId)} {r.person} {r.startTime.replace('T', ' ')} {r.endTime.replace('T', ' ')} = 4 ? 'text-red-600' : r.duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}> {r.duration}h {reasonName(r.reasonId)} {r.remark || '-'} {r.createdAt}
)}
{/* Modal */} {showModal && ( ({ id: p.id, name: p.name }))} projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))} versions={allVersions} reasons={reasons} requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))} onClose={() => setShowModal(false)} onSubmit={(data) => { createRecord(data as any); setShowModal(false); }} /> )} {/* Reason Drawer */} {showReasonDrawer && ( setShowReasonDrawer(false)} onAdd={addReason} onUpdate={updateReason} onDelete={deleteReason} /> )}
); } function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set { const ids = new Set([departmentId]); let changed = true; while (changed) { changed = false; for (const department of departments) { if (department.parentId && ids.has(department.parentId) && !ids.has(department.id)) { ids.add(department.id); changed = true; } } } return ids; } function buildDepartmentRows(departments: Department[]): Array<{ department: Department; depth: number }> { const rows: Array<{ department: Department; depth: number }> = []; const childrenByParent = new Map(); for (const department of departments) { const key = department.parentId ?? ''; const children = childrenByParent.get(key) ?? []; children.push(department); childrenByParent.set(key, children); } const append = (parentId: string, depth: number) => { const children = [...(childrenByParent.get(parentId) ?? [])].sort((a, b) => a.order - b.order); for (const child of children) { rows.push({ department: child, depth }); append(child.id, depth + 1); } }; append('', 0); return rows; } function DepartmentButton({ active, label, count, hours, depth = 0, icon, onClick }: { active: boolean; label: string; count: number; hours: number; depth?: number; icon?: ReactNode; onClick: () => void; }) { return ( ); } function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: { defaultPerson: string; products: { id: string; name: string }[]; projects: { id: string; name: string; productId: string }[]; versions: { id: string; name: string; projectId?: string }[]; reasons: { id: string; name: string }[]; requirements: { id: string; title: string; versionId?: string }[]; onClose: () => void; onSubmit: (data: any) => void; }) { const [productId, setProductId] = useState(''); const [projectId, setProjectId] = useState(''); const [versionId, setVersionId] = useState(''); const [startTime, setStartTime] = useState(''); const [endTime, setEndTime] = useState(''); const [reasonId, setReasonId] = useState(''); const [remark, setRemark] = useState(''); const [requirementId, setRequirementId] = useState(''); const [endTimeError, setEndTimeError] = useState(''); const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0; const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects; const filteredVersions = projectId ? versions.filter((v) => (v as any).projectId === projectId) : []; const filteredRequirements = versionId ? requirements.filter((r) => r.versionId === versionId) : []; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!projectId || !defaultPerson.trim() || !startTime || !endTime || !reasonId) return; if (new Date(endTime).getTime() <= new Date(startTime).getTime()) { setEndTimeError('结束时间必须晚于开始时间'); return; } setEndTimeError(''); onSubmit({ projectId, versionId: versionId || undefined, requirementId: requirementId || undefined, person: defaultPerson.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined }); }; return (
e.stopPropagation()}>

新建加班记录

{ setStartTime(next); setEndTimeError(''); }} placeholder="选择加班开始时间" defaultHour={19} />
{ setEndTime(next); setEndTimeError(''); }} placeholder="选择加班结束时间" defaultHour={21} popoverAlign="right" />
{duration > 0 && (
时长:= 4 ? 'text-red-600' : duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>{duration} 小时
)}
{filteredRequirements.length > 0 && (
)}