Files
ftb-project-management/apps/web/app/projects/[id]/page.tsx
2026-06-30 11:00:07 +08:00

547 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, Users, Tag, ChevronDown } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
import type { DevTask } from '@/lib/dev-task';
import type { TestCase } from '@/lib/test-case';
import type { Bug } from '@/lib/bug';
/* ─── StatCard ─── */
function StatCard({ value, label }: { value: number | string; label: string }) {
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-2xl font-bold text-[var(--ink)]">{value}</div>
<div className="text-xs text-[var(--ink-muted)] mt-1">{label}</div>
</div>
);
}
function HoursStatCard({ estimate, actual }: { estimate: number; actual: number }) {
const overrun = actual > estimate && estimate > 0;
const underrun = actual > 0 && actual < estimate;
const tone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink)]';
const dayStr = (h: number) => {
const d = h / 8;
return Number.isInteger(d) ? String(d) : d.toFixed(1).replace(/\.0$/, '');
};
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-baseline gap-1.5">
<span className={`text-2xl font-bold tabular-nums ${tone}`}>{actual > 0 ? `${actual}h` : '—'}</span>
<span className="text-xs text-[var(--ink-muted)] tabular-nums">/ {estimate}h</span>
</div>
<div className="text-xs text-[var(--ink-muted)] mt-1">
/ {estimate > 0 && <span className="ml-1">{actual > 0 ? `${dayStr(actual)} / ` : ''}{dayStr(estimate)}</span>}
</div>
</div>
);
}
/* ─── ProgressBar (for expanded released cards) ─── */
function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) {
return (
<div className="flex items-center gap-2">
<span className="w-12 text-xs text-[var(--ink-soft)] shrink-0">{ROLE_LABEL[role]}</span>
<div className="flex-1 h-2 rounded-full bg-zinc-100 overflow-hidden">
<div className="h-full rounded-full bg-blue-500 transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs text-[var(--ink-muted)] w-8 text-right">{percent}%</span>
<span className="text-xs text-[var(--ink-muted)] w-10 text-right">
{daysSpent === 0 ? '-' : `${daysSpent}`}
</span>
</div>
);
}
/* ─── VersionCard ─── */
function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requirements, onNavigate }: {
version: VersionWithContext;
progress: number;
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
requirements: { id: string; versionId?: string }[];
onNavigate: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
// 与版本详情一致:取所有阶段最早的实际开始
const versionData = useMemo(() => {
const vPlans = plans.filter((p) => p.versionId === version.id);
const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTCs = testCases.filter((c) => c.versionId === version.id);
const vBugs = bugs.filter((b) => b.versionId === version.id);
const startDates: string[] = [];
vPlans.forEach((p) => {
if (p.actualStartAt) startDates.push(p.actualStartAt);
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
});
vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
const actualStartDisplay = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null);
// 实际截止:取所有阶段最晚完成
const endDates: string[] = [];
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
vDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
let totalDays = 0;
if (earliestStart) {
const start = new Date(earliestStart);
start.setHours(0, 0, 0, 0);
const end = version.status === 'released' && version.releaseDate ? new Date(version.releaseDate) : new Date();
end.setHours(0, 0, 0, 0);
totalDays = Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}
return { vPlans, vDevTasks, vTCs, vBugs, totalDays, actualStart: actualStartDisplay, actualEnd: actualEndDisplay };
}, [version, plans, devTasks, testCases, bugs, requirements]);
// 状态胶囊数据 — 与版本详情一致
const stageProgress = useMemo(() => {
const { vPlans, vDevTasks, vTCs, vBugs } = versionData;
const sp: any = {};
const calcGroupProgress = (group: VersionPlan[], type: 'research' | 'product' | 'ui') => {
if (group.length === 0) return 0;
let totalItems = 0;
let doneItems = 0;
for (const p of group) {
if (type === 'research') {
const tasks = p.tasks || [];
const count = Math.max(tasks.length, 1);
totalItems += count;
if (p.status === 'completed') doneItems += count;
else doneItems += tasks.filter((t) => t.status === 'completed').length;
} else {
const summary = getRequirementCoverageSummary(p);
const count = Math.max(summary.total, 1);
totalItems += count;
if (p.status === 'completed') doneItems += count;
else {
doneItems += summary.completed;
}
}
}
return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
};
const getPlanStatus = (group: VersionPlan[]): 'idle' | 'active' | 'done' => {
if (group.length === 0) return 'idle';
if (group.every((p) => p.status === 'completed')) return 'done';
if (group.some((p) => p.status === 'in_progress')) return 'active';
return 'idle';
};
const research = vPlans.filter((p) => p.type === 'research');
const product = vPlans.filter((p) => p.type === 'product');
const ui = vPlans.filter((p) => p.type === 'ui');
if (research.length > 0) sp['requirement'] = { percent: calcGroupProgress(research, 'research'), status: getPlanStatus(research) };
if (product.length > 0) sp['product_design'] = { percent: calcGroupProgress(product, 'product'), status: getPlanStatus(product) };
if (ui.length > 0) sp['ui_design'] = { percent: calcGroupProgress(ui, 'ui'), status: getPlanStatus(ui) };
if (vDevTasks.length > 0) {
const devProgress = calcDevTaskProgress(vDevTasks);
const allSubmitted = vDevTasks.every((t) => t.status === 'submitted');
const hasActive = vDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
sp['dev'] = { percent: devProgress, status: allSubmitted ? 'done' : hasActive ? 'active' : 'idle' };
}
if (vTCs.length > 0) {
const executed = vTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const tp = Math.round((executed / vTCs.length) * 100);
const allPassed = vTCs.every((c) => c.status === 'passed');
const hasRunning = vTCs.some((c) => c.status === 'running');
sp['testing'] = { percent: tp, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' };
}
if (vBugs.length > 0) {
const closedBugs = vBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bp = Math.round((closedBugs / vBugs.length) * 100);
const allClosed = vBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
sp['bug'] = { percent: bp, status: allClosed ? 'done' : closedBugs > 0 || vBugs.length > 0 ? 'active' : 'idle' };
}
return sp;
}, [versionData]);
const totalDays = versionData.totalDays;
const displayStatus = VERSION_STATUS_LABEL[version.status] ?? '开发中';
const displayBg = VERSION_STATUS_BG[version.status] ?? 'bg-blue-500/10 text-blue-600';
if (version.status === 'planned') {
return (
<div className="rounded-xl border border-dashed border-[var(--line)] px-4 py-3">
<div className="flex items-center gap-2">
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<span className="text-[11px] text-[var(--ink-muted)]"></span>
</div>
</div>
);
}
if (version.status === 'released') {
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<span onClick={(e) => { e.stopPropagation(); onNavigate(version.id); }} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<span className="flex-1 text-[11px] text-[var(--ink-muted)] flex items-center gap-1">
<Calendar className="h-3 w-3" />
{versionData.actualStart ?? version.startDate ?? '-'}
<span className="mx-1"></span>
{versionData.actualEnd ?? version.releaseDate ?? '-'}
<span className="ml-1"> {totalDays} </span>
</span>
<ChevronDown className={`h-3.5 w-3.5 text-[var(--ink-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
{expanded && (
<div className="px-4 pb-4 pt-2 border-t border-[var(--line-soft)] space-y-3">
<CapsuleStages stageProgress={stageProgress} />
<MemberChips members={version.members ?? []} />
</div>
)}
</div>
);
}
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<div className="flex-1 flex items-center gap-2 ml-2">
<div className="flex-1 h-1.5 rounded-full bg-zinc-100 max-w-[160px]">
<div className="h-1.5 rounded-full bg-blue-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{progress}%</span>
</div>
</div>
<div className="mb-3"><CapsuleStages stageProgress={stageProgress} /></div>
<div className="flex items-center justify-between text-[11px] text-[var(--ink-muted)] mb-3 pb-3 border-b border-[var(--line-soft)]">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{versionData.actualStart ?? version.startDate ?? '-'}
<span className="mx-1"></span>
{version.expectedReleaseDate ?? '-'}
{versionData.actualEnd && (
<>
<span className="mx-1">|</span>
{versionData.actualEnd}
</>
)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> {totalDays}
</span>
</div>
<MemberChips members={version.members ?? []} />
</div>
);
}
/* ─── TeamSection (compact, overflow with tooltip) ─── */
function TeamSection({ teamByRole }: { teamByRole: Record<string, Record<string, number>> }) {
const MAX_VISIBLE = 4;
return (
<section>
<div className="flex items-center gap-2 mb-3">
<Users className="h-4 w-4 text-[var(--ink-soft)]" />
<h2 className="text-sm font-semibold text-[var(--ink)]"></h2>
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
{ROLES.map((role) => {
const peopleMap = teamByRole[role.key] || {};
const people = Object.entries(peopleMap).sort((a, b) => (b[1] as number) - (a[1] as number));
if (people.length === 0) return null;
const visible = people.slice(0, MAX_VISIBLE);
const hidden = people.slice(MAX_VISIBLE);
return (
<div key={role.key} className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-[var(--ink-muted)]">{role.label}</span>
<div className="flex items-center gap-1">
{visible.map(([name, count]) => (
<span key={name} className="inline-flex items-center h-5 px-1.5 rounded bg-[var(--bg-subtle)] text-[11px] text-[var(--ink-soft)]">
{name}<span className="text-[var(--ink-muted)] ml-0.5">{'×'}{count as number}</span>
</span>
))}
{hidden.length > 0 && (
<span className="relative group">
<span className="inline-flex items-center h-5 px-1.5 rounded bg-[var(--bg-subtle)] text-[11px] text-[var(--ink-muted)] cursor-default">
+{hidden.length}
</span>
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:flex flex-col items-center z-10">
<span className="whitespace-nowrap rounded-lg bg-zinc-800 px-2.5 py-1.5 text-[11px] text-white shadow-lg">
{hidden.map(([n, c]) => `${n}(×${c})`).join(', ')}
</span>
<span className="h-1.5 w-1.5 rotate-45 bg-zinc-800 -mt-1" />
</span>
</span>
)}
</div>
</div>
);
})}
</div>
</div>
</section>
);
}
/* ─── Status Filter ─── */
const FILTER_OPTIONS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'requirement', label: '调研' },
{ key: 'product_design', label: '产品设计' },
{ key: 'ui_design', label: 'UI设计' },
{ key: 'dev', label: '开发' },
{ key: 'testing', label: '测试' },
{ key: 'released', label: '已发布' },
{ key: 'planned', label: '规划中' },
];
/* ─── Main Page ─── */
export default function ProjectDetailPage() {
const params = useParams();
const router = useRouter();
const projectId = params.id as string;
const { overview, fetchOverview } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const [statusFilter, setStatusFilter] = useState<string>('all');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
const { roles } = useMemberStore();
const isSuperAdmin = useMemo(() => {
const r = roles.find((x) => x.id === user?.roleId);
return !!r && r.permissions.includes('*');
}, [roles, user?.roleId]);
const sortedVersions = useMemo(() => {
if (!project) return [];
let list = [...project.versions];
// 只显示当前用户参与的版本members为空时所有人可见超管可见全部
if (!isSuperAdmin) {
list = list.filter((v) => {
const ms = v.members ?? [];
if (ms.length === 0) return true;
return ms.some((m) => m.name === currentUserName);
});
}
if (statusFilter !== 'all') {
if (statusFilter === 'planned') {
list = list.filter((v) => v.status === 'planned');
} else if (statusFilter === 'released') {
list = list.filter((v) => v.status === 'released');
} else {
list = list.filter((v) => v.status === 'developing' && v.currentStage === statusFilter);
}
}
return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [project, statusFilter, isSuperAdmin, currentUserName]);
// Compute actual overall progress per version
const versionProgressMap = useMemo(() => {
if (!project) return {} as Record<string, number>;
const map: Record<string, number> = {};
for (const v of project.versions) {
const vPlans = plans.filter((p) => p.versionId === v.id);
const vReqs = requirements.filter((r) => r.versionId === v.id);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTestCases = testCases.filter((c) => c.versionId === v.id);
const segments: number[] = [];
const researchPlans = vPlans.filter((p) => p.type === 'research');
if (researchPlans.length > 0) {
const totals = researchPlans.reduce((acc, p) => {
const tasks = p.tasks || [];
acc.total += tasks.length;
acc.done += tasks.filter((t) => t.status === 'completed').length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const productPlans = vPlans.filter((p) => p.type === 'product');
if (productPlans.length > 0) {
const totals = productPlans.reduce((acc, p) => {
const summary = getRequirementCoverageSummary(p);
acc.total += summary.total;
acc.done += summary.completed;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const uiPlans = vPlans.filter((p) => p.type === 'ui');
if (uiPlans.length > 0) {
const totals = uiPlans.reduce((acc, p) => {
const summary = getRequirementCoverageSummary(p);
acc.total += summary.total;
acc.done += summary.completed;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
if (vDevTasks.length > 0) {
const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
let devProgress: number;
if (totalEstimate === 0) {
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
} else {
const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
devProgress = weighted / totalEstimate;
}
segments.push(devProgress);
}
if (vTestCases.length > 0) {
const executed = vTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
segments.push((executed / vTestCases.length) * 100);
}
map[v.id] = segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
return map;
}, [project, plans, requirements, devTasks, testCases]);
const stats = useMemo(() => {
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 };
const total = project.versions.length;
const released = project.versions.filter((v) => v.status === 'released').length;
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
const versionIds = new Set(project.versions.map((v) => v.id));
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id));
const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId));
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
}, [project, requirements, bugs, devTasks, projectId]);
const teamByRole = useMemo(() => {
if (!project) return {} as Record<string, Record<string, number>>;
const map: Record<string, Record<string, number>> = {};
ROLES.forEach((r) => (map[r.key] = {}));
project.versions.forEach((v) => {
(v.members ?? []).forEach((m) => {
if (!map[m.role]) map[m.role] = {};
map[m.role][m.name] = (map[m.role][m.name] || 0) + 1;
});
});
return map;
}, [project]);
if (!project) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-[var(--ink-muted)]"></p>
<button onClick={() => router.push('/projects')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
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">
<button onClick={() => router.push('/projects')} className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<span className="ml-2 text-[var(--ink-muted)]">/</span>
<span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{project.name}</span>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]">
<Package className="h-3 w-3" /><span>{project.productName}</span>
</div>
</header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
<div className="space-y-5">
<div className="grid grid-cols-5 gap-4">
<StatCard value={stats.total} label="总版本数" />
<StatCard value={stats.released} label="已开发" />
<StatCard value={stats.reqCount} label="需求数" />
<StatCard value={stats.bugCount} label="Bug 总数" />
<HoursStatCard estimate={stats.estimateHours} actual={stats.actualHours} />
</div>
<TeamSection teamByRole={teamByRole} />
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-[var(--ink-soft)]" />
<h2 className="text-sm font-semibold text-[var(--ink)]"></h2>
</div>
<div className="flex items-center gap-0.5 rounded-lg bg-[var(--bg-subtle)] p-0.5">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setStatusFilter(opt.key)}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-colors ${statusFilter === opt.key ? 'bg-[var(--bg-card)] text-[var(--ink)] shadow-sm' : 'text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="space-y-2.5">
{sortedVersions.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] p-6 text-center text-xs text-[var(--ink-muted)]"></div>
) : (
sortedVersions.map((v) => <VersionCard key={v.id} version={v} progress={versionProgressMap[v.id] ?? 0} plans={plans} devTasks={devTasks} testCases={testCases} bugs={bugs} requirements={requirements} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>
</div>
</div>
</div>
);
}