feat(版本): 优化概览与只读状态

关键改动:

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

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

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

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 18:18:18 +08:00
parent 3d3d56697a
commit eef3c8f000
32 changed files with 1072 additions and 289 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, X, Download } from 'lucide-react';
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';
@@ -17,6 +17,8 @@ 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 (
@@ -40,6 +42,7 @@ function OvertimePageContent() {
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);
@@ -58,7 +61,18 @@ function OvertimePageContent() {
departments,
}), [records, user, viewerRole, members, departments]);
const filtered = useMemo(() => {
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);
@@ -68,6 +82,51 @@ function OvertimePageContent() {
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); };
@@ -140,63 +199,121 @@ function OvertimePageContent() {
<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 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>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
<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 */}
@@ -224,6 +341,71 @@ function OvertimePageContent() {
);
}
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 }[];