Files
ftb-project-management/apps/web/app/overtime/page.tsx
Script Generator eef3c8f000 feat(版本): 优化概览与只读状态
关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 18:18:18 +08:00

536 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 (
<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 { 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<string, { count: number; hours: number }>();
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 (
<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)]">{visibleRecords.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>
<div className="flex min-h-0 flex-1 overflow-hidden bg-[var(--bg)]">
<aside className="flex w-[240px] shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="border-b border-[var(--line)] px-4 py-3">
<div className="flex items-center gap-2 text-[13px] font-semibold text-[var(--ink)]">
<Building2 className="h-3.5 w-3.5 text-[var(--accent)]" strokeWidth={2} />
</div>
<div className="mt-1 text-[11px] tabular-nums text-[var(--ink-muted)]">{baseFiltered.length} / {totalBaseHours}h</div>
</div>
<div className="flex-1 overflow-y-auto py-1">
<DepartmentButton
active={departmentFilter === 'all'}
label="全部部门"
count={baseFiltered.length}
hours={totalBaseHours}
icon={<FolderOpen className="h-3.5 w-3.5" strokeWidth={2} />}
onClick={() => setDepartmentFilter('all')}
/>
{departmentRows.map(({ department, depth }) => {
const stat = departmentStats.get(`${department.id}:tree`) ?? { count: 0, hours: 0 };
return (
<DepartmentButton
key={department.id}
active={departmentFilter === department.id}
label={department.name}
count={stat.count}
hours={Math.round(stat.hours * 10) / 10}
depth={depth}
onClick={() => setDepartmentFilter(department.id)}
/>
);
})}
{unknownStats.count > 0 && (
<DepartmentButton
active={departmentFilter === 'unknown'}
label="未匹配部门"
count={unknownStats.count}
hours={Math.round(unknownStats.hours * 10) / 10}
onClick={() => setDepartmentFilter('unknown')}
/>
)}
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[14px] font-semibold text-[var(--ink)]">{selectedDepartmentName}</h2>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filtered.length}</span>
</div>
<p className="mt-0.5 text-[11px] tabular-nums text-[var(--ink-muted)]"> {selectedHours}h</p>
</div>
</div>
<div className="flex-1 overflow-y-auto 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="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] 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>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
</div>
</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 collectDepartmentTreeIds(departments: Department[], departmentId: string): Set<string> {
const ids = new Set<string>([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<string, Department[]>();
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 (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-2 px-4 py-2 text-left text-[12px] transition-colors ${
active ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
style={{ paddingLeft: `${16 + depth * 16}px` }}
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md ${active ? 'bg-white/70' : 'bg-[var(--bg-subtle)]'}`}>
{icon ?? <Building2 className="h-3.5 w-3.5" strokeWidth={2} />}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="shrink-0 text-[11px] tabular-nums text-[var(--ink-muted)]">{count}</span>
<span className="w-12 shrink-0 text-right text-[11px] tabular-nums text-[var(--ink-muted)]">{hours}h</span>
</button>
);
}
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>
);
}