344 lines
20 KiB
TypeScript
344 lines
20 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { Search, Plus, X, Download } from 'lucide-react';
|
||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||
import { useProductStore } from '@/stores/useProductStore';
|
||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||
import { useAuthStore } from '@/stores/useAuthStore';
|
||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||
import { calcDuration } 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';
|
||
|
||
export default function OvertimePage() {
|
||
return (
|
||
<RouteGuard permission="overtime:view">
|
||
<OvertimePageContent />
|
||
</RouteGuard>
|
||
);
|
||
}
|
||
|
||
function OvertimePageContent() {
|
||
const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
|
||
const { overview, fetchOverview } = useProductStore();
|
||
const { requirements, fetchRequirements } = useRequirementStore();
|
||
const user = useAuthStore((s) => s.user);
|
||
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 [showModal, setShowModal] = useState(false);
|
||
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
|
||
|
||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||
|
||
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 filtered = useMemo(() => {
|
||
let list = [...records];
|
||
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;
|
||
}, [records, search, projectFilter, reasonFilter, monthFilter]);
|
||
|
||
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 (
|
||
<div className="flex h-full flex-col">
|
||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||
<div className="flex items-center gap-2.5">
|
||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">加班记录</h1>
|
||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{records.length}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={handleExport} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||
<Download className="h-3.5 w-3.5" strokeWidth={2} />
|
||
导出
|
||
</button>
|
||
<button onClick={() => setShowReasonDrawer(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||
原因管理
|
||
</button>
|
||
<button onClick={handleCreate} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||
新建记录
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Filters */}
|
||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||
<input value={search} onChange={(e) => 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" />
|
||
</div>
|
||
<FilterSelect
|
||
value={projectFilter}
|
||
onChange={setProjectFilter}
|
||
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
|
||
placeholder="全部项目"
|
||
allLabel="全部项目"
|
||
/>
|
||
<FilterSelect
|
||
value={reasonFilter}
|
||
onChange={setReasonFilter}
|
||
options={reasons.map((r) => ({ value: r.id, label: r.name }))}
|
||
placeholder="全部原因"
|
||
allLabel="全部原因"
|
||
/>
|
||
<MonthPicker value={monthFilter} onChange={setMonthFilter} placeholder="全部月份" />
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
|
||
{filtered.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">暂无加班记录</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||
<table className="w-full text-left text-[13px]">
|
||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">项目</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">版本</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">加班人</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">开始时间</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">结束时间</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">时长</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">加班原因</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">备注</th>
|
||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">创建日期</th>
|
||
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paged.map((r) => (
|
||
<tr key={r.id} className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
|
||
<td className="px-4 py-3 text-[var(--ink)]">{projectName(r.projectId)}</td>
|
||
<td className="px-4 py-3 text-[var(--ink-soft)]">{versionName(r.versionId)}</td>
|
||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{r.person}</td>
|
||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.startTime.replace('T', ' ')}</td>
|
||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.endTime.replace('T', ' ')}</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`inline-flex items-center gap-1 font-medium tabular-nums ${r.duration >= 4 ? 'text-red-600' : r.duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>
|
||
{r.duration}h
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className="inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
|
||
{reasonName(r.reasonId)}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-[12px] text-[var(--ink-muted)] max-w-[120px] truncate">{r.remark || '-'}</td>
|
||
<td className="px-4 py-3 tabular-nums text-[var(--ink-muted)]">{r.createdAt}</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<button onClick={() => deleteRecord(r.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Modal */}
|
||
{showModal && (
|
||
<OvertimeModal
|
||
defaultPerson={user?.name ?? ''}
|
||
products={overview.map((p) => ({ 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 && (
|
||
<DictDrawer open={true} title="加班原因管理" items={reasons} onClose={() => setShowReasonDrawer(false)} onAdd={addReason} onUpdate={updateReason} onDelete={deleteReason} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">新建加班记录</h3>
|
||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||
</div>
|
||
<form onSubmit={handleSubmit} className="space-y-3">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">产品 *</label>
|
||
<select value={productId} onChange={(e) => { setProductId(e.target.value); setProjectId(''); setVersionId(''); }} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||
<option value="">选择产品</option>
|
||
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">项目 *</label>
|
||
<select value={projectId} onChange={(e) => { setProjectId(e.target.value); setVersionId(''); }} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||
<option value="">选择项目</option>
|
||
{filteredProjects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">版本 *</label>
|
||
<select value={versionId} onChange={(e) => setVersionId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||
<option value="">选择版本</option>
|
||
{filteredVersions.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">加班人</label>
|
||
<input value={defaultPerson} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">开始时间 *</label>
|
||
<WorkDateTimePicker
|
||
value={startTime}
|
||
onChange={(next) => { setStartTime(next); setEndTimeError(''); }}
|
||
placeholder="选择加班开始时间"
|
||
defaultHour={19}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">结束时间 *</label>
|
||
<WorkDateTimePicker
|
||
value={endTime}
|
||
onChange={(next) => { setEndTime(next); setEndTimeError(''); }}
|
||
placeholder="选择加班结束时间"
|
||
defaultHour={21}
|
||
popoverAlign="right"
|
||
/>
|
||
<FieldError message={endTimeError} />
|
||
</div>
|
||
</div>
|
||
{duration > 0 && (
|
||
<div className="text-[12px] text-[var(--ink-muted)]">
|
||
时长:<span className={`font-medium ${duration >= 4 ? 'text-red-600' : duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>{duration} 小时</span>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">加班原因 *</label>
|
||
<select value={reasonId} onChange={(e) => setReasonId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||
<option value="">选择原因</option>
|
||
{reasons.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||
</select>
|
||
</div>
|
||
{filteredRequirements.length > 0 && (
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">关联需求</label>
|
||
<select value={requirementId} onChange={(e) => setRequirementId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||
<option value="">不关联</option>
|
||
{filteredRequirements.map((r) => <option key={r.id} value={r.id}>{r.title}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">备注</label>
|
||
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} placeholder="可选" className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">创建</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|