feat(测试用例): 展示预估和实际耗时

This commit is contained in:
Script Generator
2026-06-25 14:55:45 +08:00
parent 88acf30296
commit 5723356d08
4 changed files with 108 additions and 18 deletions

View File

@@ -1,12 +1,15 @@
'use client'; 'use client';
import { useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import type { Priority } from '@/lib/derive'; import type { Priority } from '@/lib/derive';
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
interface Props { interface Props {
versionId: string; versionId: string;
@@ -18,30 +21,66 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
const { createTestCase } = useTestCaseStore(); const { createTestCase } = useTestCaseStore();
const { requirements } = useRequirementStore(); const { requirements } = useRequirementStore();
const { members } = useMemberStore(); const { members } = useMemberStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const versionReqs = useMemo( const versionReqs = useMemo(
() => requirements.filter((r) => requirementIds.includes(r.id)), () => requirements.filter((r) => requirementIds.includes(r.id)),
[requirements, requirementIds], [requirements, requirementIds],
); );
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || ''); const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || '');
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2'); const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id);
const selectedCategory = useMemo(
() => categories.find((category) => category.id === categoryId) ?? testCategories[0],
[categories, categoryId, testCategories],
);
const [estimateHours, setEstimateHours] = useState(0.5);
const [assigneeId, setAssigneeId] = useState(user?.name || ''); const [assigneeId, setAssigneeId] = useState(user?.name || '');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [prototypeNotes, setPrototypeNotes] = useState('');
const canSubmit = title.trim(); useEffect(() => {
if (testCategories.length === 0) return;
if (testCategories.some((category) => category.id === categoryId)) return;
const next = testCategories[0];
setCategoryId(next.id);
setEstimateHours(getDefaultTestCaseEstimateHours(next.code));
}, [categoryId, testCategories]);
const handleCategoryChange = (nextCategoryId: string) => {
setCategoryId(nextCategoryId);
const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId);
setEstimateHours(getDefaultTestCaseEstimateHours(category?.code));
};
const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours);
const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0;
const handleSubmit = () => { const handleSubmit = () => {
if (!canSubmit) return; if (!canSubmit) return;
const reqRef = versionReqs.find((r) => r.id === requirementId);
const references = [
...(reqRef ? [{ type: 'requirement' as const, id: reqRef.code, label: `${reqRef.code} ${reqRef.title}` }] : []),
...prototypeNotes.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).map((note) => ({
type: 'prototype_note' as const,
id: note,
label: note,
})),
];
createTestCase({ createTestCase({
versionId, versionId,
requirementId: requirementId || undefined, requirementId: requirementId || undefined,
title: title.trim(), title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
categoryId,
priority, priority,
estimateHours: normalizedEstimateHours,
assigneeId: assigneeId || undefined, assigneeId: assigneeId || undefined,
references: references.length > 0 ? references : undefined,
createdBy: user?.name || '系统', createdBy: user?.name || '系统',
}); });
onClose(); onClose();
@@ -73,6 +112,27 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)} {(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
</select> </select>
</div> </div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<select value={categoryId} onChange={(e) => handleCategoryChange(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
{testCategories.map((category) => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<input
type="number"
min="0.25"
max="2"
step="0.25"
value={estimateHours}
onChange={(e) => setEstimateHours(Number(e.target.value))}
onBlur={() => setEstimateHours(normalizedEstimateHours)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
/>
</div>
<div> <div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label> <label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"> <select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
@@ -85,6 +145,11 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> & </label> <label className="block text-[12px] text-[var(--ink-soft)] mb-1"> & </label>
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="1. 操作步骤...&#10;2. 预期结果..." /> <textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="1. 操作步骤...&#10;2. 预期结果..." />
</div> </div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<input value={prototypeNotes} onChange={(e) => setPrototypeNotes(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="如 QY0007, QY0023" />
<p className="mt-1 text-[11px] text-[var(--ink-muted)]"></p>
</div>
</div> </div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4"> <div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
<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 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>

View File

@@ -8,8 +8,10 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore'; import { useBugStore } from '@/stores/useBugStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { calcActualHoursByDates } from '@/lib/dev-task'; import { CategoryChip } from '@/components/dev-task/CategoryChip';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format'; import { formatDateTime } from '@/lib/format';
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import type { TestCaseStatus } from '@/lib/test-case'; import type { TestCaseStatus } from '@/lib/test-case';
@@ -26,6 +28,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
const { bugs } = useBugStore(); const { bugs } = useBugStore();
const { requirements } = useRequirementStore(); const { requirements } = useRequirementStore();
const { members } = useMemberStore(); const { members } = useMemberStore();
const { categories } = useTaskCategoryStore();
const [showTransfer, setShowTransfer] = useState(false); const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState(''); const [transferTo, setTransferTo] = useState('');
@@ -33,8 +36,11 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
if (!tc) return null; if (!tc) return null;
const requirement = requirements.find((r) => r.id === tc.requirementId); const requirement = requirements.find((r) => r.id === tc.requirementId);
const category = categories.find((c) => c.id === tc.categoryId);
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id); const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status]; const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
const estimateHours = getTestCaseEstimateHours(tc);
const actualHours = getTestCaseActualHours(tc);
const [failReason, setFailReason] = useState(''); const [failReason, setFailReason] = useState('');
const [blockReason, setBlockReason] = useState(''); const [blockReason, setBlockReason] = useState('');
@@ -128,8 +134,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
{showFailInput && ( {showFailInput && (
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="失败原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus /> <input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="不通过原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white"></button> <button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white"></button>
<button onClick={() => setShowFailInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button> <button onClick={() => setShowFailInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div> </div>
)} )}
@@ -142,7 +148,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
</div> </div>
)} )}
{tc.failReason && <div className="text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />{tc.failReason}</div>} {tc.failReason && <div className="text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />{tc.failReason}</div>}
{tc.blockReason && <div className="text-[12px] text-orange-600 bg-orange-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />{tc.blockReason}</div>} {tc.blockReason && <div className="text-[12px] text-orange-600 bg-orange-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />{tc.blockReason}</div>}
</div> </div>
@@ -151,12 +157,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3"></div> <div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3"></div>
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]"> <div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
<div className="flex items-center gap-1.5"><span className="text-[var(--ink-muted)]"></span><CategoryChip category={category} /></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{formatWorkHours(estimateHours)}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdBy}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
{tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>} {tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
{tc.completedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.completedAt)}</span></div>} {tc.completedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.completedAt)}</span></div>}
{tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{calcActualHoursByDates(tc.startedAt, tc.completedAt)}h</span></div>} {tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{formatWorkHours(actualHours)}</span></div>}
</div> </div>
</div> </div>

View File

@@ -2,12 +2,15 @@
import { memo } from 'react'; import { memo } from 'react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge'; import { TestCaseStatusBadge } from './TestCaseStatusBadge';
import { getTestCaseActualHours } from '@/lib/test-case'; import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours'; import { formatWorkHours } from '@/lib/work-hours';
import { CategoryChip } from '@/components/dev-task/CategoryChip';
import type { TestCase } from '@/lib/test-case'; import type { TestCase } from '@/lib/test-case';
import type { TaskCategory } from '@/lib/task-category';
interface Props { interface Props {
testCase: TestCase; testCase: TestCase;
category?: TaskCategory;
bugCount: number; bugCount: number;
onClick?: () => void; onClick?: () => void;
} }
@@ -19,17 +22,26 @@ const PRIORITY_DOT: Record<string, string> = {
P3: 'bg-zinc-300', P3: 'bg-zinc-300',
}; };
function TestCaseRowImpl({ testCase, bugCount, onClick }: Props) { function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
const estimateHours = getTestCaseEstimateHours(testCase);
const actualHours = getTestCaseActualHours(testCase); const actualHours = getTestCaseActualHours(testCase);
return ( return (
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0"> <div onClick={onClick} className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${testCase.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}>
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} /> <span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span> <span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span> <div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-[13px] text-[var(--ink)] truncate">{testCase.title}</span>
<CategoryChip category={category} />
{testCase.aiDraft && (
<span className="text-[10px] text-purple-600 bg-purple-100 px-1.5 py-0.5 rounded shrink-0" title="AI 拆解草案,编辑后会移除标记">
AI
</span>
)}
</div>
<TestCaseStatusBadge status={testCase.status} /> <TestCaseStatusBadge status={testCase.status} />
{actualHours > 0 && ( <span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span> {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${formatWorkHours(estimateHours)}`}
)} </span>
{bugCount > 0 && ( {bugCount > 0 && (
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span> <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
)} )}

View File

@@ -7,11 +7,12 @@ import { useBugStore } from '@/stores/useBugStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { TestCaseRow } from './TestCaseRow'; import { TestCaseRow } from './TestCaseRow';
import { TestCaseCreateModal } from './TestCaseCreateModal'; import { TestCaseCreateModal } from './TestCaseCreateModal';
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer'; import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
import { BugCreateModal } from '@/components/bug/BugCreateModal'; import { BugCreateModal } from '@/components/bug/BugCreateModal';
import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
@@ -29,9 +30,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const { tasks: devTasks } = useDevTaskStore(); const { tasks: devTasks } = useDevTaskStore();
const { requirements } = useRequirementStore(); const { requirements } = useRequirementStore();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { categories, fetchCategories } = useTaskCategoryStore();
useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
const versionCases = useMemo( const versionCases = useMemo(
@@ -46,6 +49,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted'); const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const stats = calcTestProgress(versionCases); const stats = calcTestProgress(versionCases);
const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases);
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]); const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
const [filterAssignee, setFilterAssignee] = useState(''); const [filterAssignee, setFilterAssignee] = useState('');
@@ -95,6 +99,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
}, [paged]); }, [paged]);
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
const bugCountByCase = useMemo(() => { const bugCountByCase = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1); for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
@@ -125,7 +130,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0"> <div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} /> <div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
</div> </div>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total} · {stats.passed} · {stats.failed}</span> <span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total} · {stats.passed} · {stats.failed} · {formatWorkHours(tcEstimateHours)} / {formatWorkHours(tcActualHours)}</span>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.passRate}%</span> <span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.passRate}%</span>
{tcManhours > 0 && ( {tcManhours > 0 && (
<span className="text-[11px] text-[var(--ink-muted)] shrink-0"> {formatWorkHours(tcCalendarHours)} / {formatWorkHours(tcManhours)}</span> <span className="text-[11px] text-[var(--ink-muted)] shrink-0"> {formatWorkHours(tcCalendarHours)} / {formatWorkHours(tcManhours)}</span>
@@ -180,7 +185,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} /> <input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<TestCaseRow testCase={c} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} /> <TestCaseRow testCase={c} category={categoryMap.get(c.categoryId)} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
</div> </div>
</div> </div>
))} ))}