feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -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 }[];
|
||||
|
||||
Reference in New Issue
Block a user