feat: 开发任务时间字段重构 + 双口径耗时 + 搜索 + 性能优化
核心变更: - DevTask 字段重构:startDate/dueDate/estimateHours/actualHours 替换为 expectedStartAt/expectedEndAt/actualStartAt/actualEndAt(含时分),预计/实际工时按工作时段(9:00-12:00 + 13:00-18:00)派生计算 - 状态机自动化:到点自动切开发中、未到可手动开干、超期需填延后原因 - 新建 lib/work-hours.ts:calcWorkHours(工作时段过滤)、formatWorkHours(X h(Y 天))、calcTwoMetrics(日历/人力双口径) - 新建 lib/dev-task-transitions.ts:状态切换守卫 - 三个 Tab 顶部统计:开发任务(预/日历/人力)、测试用例 / Bug(日历 / 人力);版本概览总人天投入改双行(日历总耗时 / 人力总投入) - 三个 Tab 加搜索框:300ms debounce + 标题/编号模糊匹配 - 性能优化:requirementIds/categories/requirements/testCases 转 Map/Set 索引、Row 组件 React.memo - 全局耗时显示统一带天数换算:8h(1天)、11h(1.4天) 新增文件: SearchInput.tsx, useDebouncedValue.ts, work-hours.ts, dev-task-transitions.ts, 设计文档 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
apps/web/components/SearchInput.tsx
Normal file
48
apps/web/components/SearchInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表搜索框(标题 + 编号模糊匹配,外部需配合 useDebouncedValue 防抖)
|
||||
*/
|
||||
export function SearchInput({ value, onChange, placeholder = '搜索标题或编号', className = '' }: Props) {
|
||||
return (
|
||||
<div className={`relative inline-flex items-center ${className}`}>
|
||||
<Search className="absolute left-2 h-3 w-3 text-[var(--ink-muted)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-6 w-72 pl-7 pr-6 rounded border border-[var(--line)] bg-[var(--bg-card)] text-[11px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-1 p-0.5 rounded hover:bg-[var(--bg-subtle)]"
|
||||
title="清除"
|
||||
>
|
||||
<X className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题 + 编号模糊匹配(case-insensitive)
|
||||
*/
|
||||
export function matchTitleOrNo(item: { title: string; no?: string }, keyword: string): boolean {
|
||||
if (!keyword) return true;
|
||||
const k = keyword.trim().toLowerCase();
|
||||
if (!k) return true;
|
||||
if (item.title.toLowerCase().includes(k)) return true;
|
||||
if (item.no && item.no.toLowerCase().includes(k)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { BugStatusBadge } from './BugStatusBadge';
|
||||
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug';
|
||||
import { formatWorkHours } from '@/lib/work-hours';
|
||||
import type { Bug } from '@/lib/bug';
|
||||
|
||||
interface Props {
|
||||
@@ -17,16 +19,22 @@ const PRIORITY_DOT: Record<string, string> = {
|
||||
P3: 'bg-zinc-300',
|
||||
};
|
||||
|
||||
export function BugRow({ bug, testCaseNo, onClick }: Props) {
|
||||
function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
|
||||
const actualHours = getBugActualHours(bug);
|
||||
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">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{bug.bugNo}</span>
|
||||
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{bug.title}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||
<BugStatusBadge status={bug.status} />
|
||||
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right">{testCaseNo}</span>}
|
||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{bug.assigneeId}</span>
|
||||
{actualHours > 0 && (
|
||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
||||
)}
|
||||
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right shrink-0">{testCaseNo}</span>}
|
||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{bug.assigneeId}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const BugRow = memo(BugRowImpl);
|
||||
|
||||
@@ -9,7 +9,10 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { BugRow } from './BugRow';
|
||||
import { BugDetailDrawer } from './BugDetailDrawer';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL } from '@/lib/bug';
|
||||
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug';
|
||||
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||
import type { BugStatus, BugSeverity } from '@/lib/bug';
|
||||
|
||||
interface Props {
|
||||
@@ -26,48 +29,50 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
|
||||
// 该版本的所有 Bug(直接通过 versionId 查询)
|
||||
const versionBugs = useMemo(
|
||||
() => bugs.filter((b) => b.versionId === versionId),
|
||||
[bugs, versionId],
|
||||
);
|
||||
|
||||
// 统计
|
||||
const openCount = versionBugs.filter((b) => b.status === 'open').length;
|
||||
const fixingCount = versionBugs.filter((b) => b.status === 'fixing').length;
|
||||
const fixedCount = versionBugs.filter((b) => b.status === 'fixed' || b.status === 'verifying').length;
|
||||
const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
|
||||
const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length;
|
||||
const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0;
|
||||
const { calendarHours: bugCalendarHours, manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]);
|
||||
|
||||
// 筛选
|
||||
const [filterAssignee, setFilterAssignee] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterSeverity, setFilterSeverity] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const filteredBugs = useMemo(() => {
|
||||
let result = versionBugs;
|
||||
if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
|
||||
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
|
||||
if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
|
||||
if (debouncedKeyword) result = result.filter((b) => matchTitleOrNo({ title: b.title, no: b.bugNo }, debouncedKeyword));
|
||||
return result;
|
||||
}, [versionBugs, filterAssignee, filterStatus, filterSeverity]);
|
||||
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
|
||||
|
||||
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
|
||||
|
||||
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
|
||||
|
||||
const assignees = useMemo(() => {
|
||||
const names = new Set(versionBugs.map((b) => b.assigneeId));
|
||||
return Array.from(names);
|
||||
}, [versionBugs]);
|
||||
|
||||
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity);
|
||||
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 统计栏 */}
|
||||
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)]">
|
||||
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<BugIcon className="h-4 w-4 text-red-500" />
|
||||
<span className="text-[13px] font-medium text-[var(--ink)]">Bug {versionBugs.length} 个</span>
|
||||
@@ -79,11 +84,14 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
||||
<span className="text-[11px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-medium">{criticalCount} 致命</span>
|
||||
)}
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">解决率 {resolveRate}%</span>
|
||||
{bugManhours > 0 && (
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">日历 {formatWorkHours(bugCalendarHours)} / 人力 {formatWorkHours(bugManhours)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="flex items-center gap-2 px-1 flex-wrap">
|
||||
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">全部修复人</option>
|
||||
{user?.name && <option value={user.name}>我的Bug</option>}
|
||||
@@ -97,11 +105,10 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
||||
<option value="">全部严重程度</option>
|
||||
{(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterSeverity(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterSeverity(''); setKeyword(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
||||
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredBugs.length} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{filteredBugs.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的 Bug' : '暂无 Bug,很好'}</p>
|
||||
@@ -109,7 +116,7 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
||||
) : (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
{paged.map((bug) => {
|
||||
const tc = testCases.find((c) => c.id === bug.testCaseId);
|
||||
const tc = testCaseMap.get(bug.testCaseId);
|
||||
return <BugRow key={bug.id} bug={bug} testCaseNo={tc?.caseNo} onClick={() => setSelectedBugId(bug.id)} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
|
||||
interface Props {
|
||||
@@ -17,6 +18,18 @@ interface Props {
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
function defaultExpectedStart(): string {
|
||||
const d = new Date();
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return isoToLocal(d.toISOString());
|
||||
}
|
||||
|
||||
function defaultExpectedEnd(): string {
|
||||
const d = new Date();
|
||||
d.setHours(18, 0, 0, 0);
|
||||
return isoToLocal(d.toISOString());
|
||||
}
|
||||
|
||||
export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) {
|
||||
const { createTask, tasks } = useDevTaskStore();
|
||||
const { categories } = useTaskCategoryStore();
|
||||
@@ -39,16 +52,46 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
||||
const [assigneeId, setAssigneeId] = useState(user?.name || '');
|
||||
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
|
||||
const [priorityManuallySet, setPriorityManuallySet] = useState(false);
|
||||
const [estimateHours, setEstimateHours] = useState<number>(8);
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [expectedStartLocal, setExpectedStartLocal] = useState(defaultExpectedStart);
|
||||
const [expectedEndLocal, setExpectedEndLocal] = useState(defaultExpectedEnd);
|
||||
const [predecessorIds, setPredecessorIds] = useState<string[]>([]);
|
||||
const [description, setDescription] = useState('');
|
||||
const [overdueReason, setOverdueReason] = useState('');
|
||||
const [overdueVersionReason, setOverdueVersionReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (categories[0] && !categoryId) setCategoryId(categories[0].id);
|
||||
}, [categories, categoryId]);
|
||||
|
||||
const expectedStartISO = localToISO(expectedStartLocal);
|
||||
const expectedEndISO = localToISO(expectedEndLocal);
|
||||
|
||||
const estimateHours = useMemo(() => {
|
||||
if (!expectedStartISO || !expectedEndISO) return 0;
|
||||
return calcWorkHours(expectedStartISO, expectedEndISO);
|
||||
}, [expectedStartISO, expectedEndISO]);
|
||||
|
||||
const selectedReq = versionReqs.find((r) => r.id === requirementId);
|
||||
const effectivePriority = priorityManuallySet ? priority : (selectedReq?.priority || priority);
|
||||
const isOverdue = !!(dueDate && versionDeadline && dueDate > versionDeadline);
|
||||
const canSubmit = title.trim() && requirementId && categoryId && assigneeId && estimateHours > 0 && (!isOverdue || overdueReason.trim());
|
||||
|
||||
const versionDeadlineISO = useMemo(() => {
|
||||
if (!versionDeadline) return null;
|
||||
const d = new Date(versionDeadline);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d.toISOString();
|
||||
}, [versionDeadline]);
|
||||
|
||||
const isOverdueVersion = !!(expectedEndISO && versionDeadlineISO && expectedEndISO > versionDeadlineISO);
|
||||
const startBeforeEnd = expectedStartISO && expectedEndISO && expectedStartISO < expectedEndISO;
|
||||
|
||||
const canSubmit =
|
||||
title.trim() &&
|
||||
requirementId &&
|
||||
categoryId &&
|
||||
assigneeId &&
|
||||
!!startBeforeEnd &&
|
||||
estimateHours > 0 &&
|
||||
(!isOverdueVersion || overdueVersionReason.trim());
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
@@ -60,16 +103,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
||||
assigneeId,
|
||||
reviewerId: undefined,
|
||||
priority: effectivePriority,
|
||||
estimateHours,
|
||||
startDate: undefined,
|
||||
dueDate: dueDate || undefined,
|
||||
completedAt: undefined,
|
||||
expectedStartAt: expectedStartISO,
|
||||
expectedEndAt: expectedEndISO,
|
||||
actualStartAt: undefined,
|
||||
actualEndAt: undefined,
|
||||
status: 'todo',
|
||||
blockReason: undefined,
|
||||
blockedById: undefined,
|
||||
predecessorIds: predecessorIds.length > 0 ? predecessorIds : undefined,
|
||||
riskLevel: undefined,
|
||||
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
||||
delayReason: undefined,
|
||||
overdueVersionReason: isOverdueVersion ? overdueVersionReason.trim() : undefined,
|
||||
createdBy: user?.name || '系统',
|
||||
});
|
||||
onCreated?.();
|
||||
@@ -109,7 +153,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计开始 *</label>
|
||||
<input type="datetime-local" value={expectedStartLocal} onChange={(e) => setExpectedStartLocal(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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计截止 *</label>
|
||||
<input type="datetime-local" value={expectedEndLocal} onChange={(e) => setExpectedEndLocal(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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">优先级</label>
|
||||
<select value={effectivePriority} onChange={(e) => { setPriority(e.target.value as Priority); setPriorityManuallySet(true); }} 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">
|
||||
@@ -117,24 +171,28 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计工时(h) *</label>
|
||||
<input type="number" min={0.5} step={0.5} value={estimateHours} onChange={(e) => setEstimateHours(parseFloat(e.target.value) || 0)} 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>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">截止日期</label>
|
||||
<input type="date" value={dueDate} onChange={(e) => setDueDate(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" />
|
||||
{versionDeadline && (
|
||||
<p className="text-[10px] text-[var(--ink-muted)] mt-0.5">版本截止:{versionDeadline}</p>
|
||||
)}
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计工时(自动)</label>
|
||||
<div className="h-9 flex items-center px-3 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] text-[13px] text-[var(--ink-soft)] tabular-nums">
|
||||
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--ink-muted)] mt-0.5">工作时段 9:00–12:00 + 13:00–18:00,跳过周末</p>
|
||||
</div>
|
||||
</div>
|
||||
{isOverdue && (
|
||||
{!startBeforeEnd && expectedStartLocal && expectedEndLocal && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-700">
|
||||
预计开始必须早于预计截止
|
||||
</div>
|
||||
)}
|
||||
{versionDeadline && (
|
||||
<p className="text-[10px] text-[var(--ink-muted)]">版本截止:{versionDeadline}</p>
|
||||
)}
|
||||
{isOverdueVersion && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
任务截止日期超出版本截止日期({versionDeadline}),请说明原因
|
||||
任务预计截止超出版本截止日期({versionDeadline}),请说明原因
|
||||
</div>
|
||||
<input value={overdueReason} onChange={(e) => setOverdueReason(e.target.value)} placeholder="超期原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" />
|
||||
<input value={overdueVersionReason} onChange={(e) => setOverdueVersionReason(e.target.value)} placeholder="超期原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" />
|
||||
</div>
|
||||
)}
|
||||
{versionTasks.length > 0 && (
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play, Trash2, Pencil, ArrowRightLeft } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, ArrowRightLeft, CalendarRange } from 'lucide-react';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours, calcActualHoursByDates } from '@/lib/dev-task';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import {
|
||||
ALLOWED_TRANSITIONS,
|
||||
DEV_TASK_STATUS_LABEL,
|
||||
DEV_TASK_STATUS_COLOR,
|
||||
formatHours,
|
||||
getEstimateHours,
|
||||
getActualHours,
|
||||
} from '@/lib/dev-task';
|
||||
import { needsDelayReason } from '@/lib/dev-task-transitions';
|
||||
import { formatShortTime } from '@/lib/work-hours';
|
||||
import type { DevTaskStatus } from '@/lib/dev-task';
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +34,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
const { members } = useMemberStore();
|
||||
const [showTransfer, setShowTransfer] = useState(false);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [showDelayInput, setShowDelayInput] = useState(false);
|
||||
const [delayReason, setDelayReason] = useState('');
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return null;
|
||||
@@ -39,9 +49,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
const [blockReason, setBlockReason] = useState(task.blockReason || '');
|
||||
const [showBlockInput, setShowBlockInput] = useState(false);
|
||||
|
||||
const estimate = useMemo(() => getEstimateHours(task), [task]);
|
||||
const actual = useMemo(() => getActualHours(task), [task]);
|
||||
const overrun = actual > estimate && estimate > 0;
|
||||
const requireDelay = task.status === 'todo' && needsDelayReason(task);
|
||||
|
||||
const handleTransition = (to: DevTaskStatus) => {
|
||||
const result = changeStatus(task.id, to);
|
||||
if (!result.ok) alert(result.message);
|
||||
if (to === 'in_progress' && requireDelay && !showDelayInput) {
|
||||
setShowDelayInput(true);
|
||||
return;
|
||||
}
|
||||
const opts = to === 'in_progress' && delayReason.trim()
|
||||
? { delayReason: delayReason.trim() }
|
||||
: undefined;
|
||||
const result = changeStatus(task.id, to, opts);
|
||||
if (!result.ok) {
|
||||
alert(result.message);
|
||||
return;
|
||||
}
|
||||
if (to === 'in_progress') {
|
||||
setShowDelayInput(false);
|
||||
setDelayReason('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlock = () => {
|
||||
@@ -58,13 +87,11 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 上下文 */}
|
||||
{contextLabel && (
|
||||
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 顶栏 */}
|
||||
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
|
||||
@@ -79,7 +106,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转交 */}
|
||||
{showTransfer && (
|
||||
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
||||
@@ -92,10 +118,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 滚动区域 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
|
||||
{/* 卡片1:关联需求 */}
|
||||
{requirement && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5">关联需求</div>
|
||||
@@ -107,7 +131,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卡片2:状态 & 操作 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -119,11 +142,15 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
<AlertTriangle className="h-3 w-3" />阻塞中
|
||||
</span>
|
||||
)}
|
||||
{requireDelay && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-orange-600 bg-orange-50 border border-orange-200 px-2 py-1 rounded-lg" title="已超过预计开始时间,开干需填延后原因">
|
||||
<AlertTriangle className="h-3 w-3" />超期未开始
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 流转按钮 */}
|
||||
{nextStatuses.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{nextStatuses.length > 0 && !showDelayInput && (
|
||||
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
{nextStatuses.map((s) => (
|
||||
<button key={s} onClick={() => handleTransition(s)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors">
|
||||
@@ -133,7 +160,20 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 阻塞管理 */}
|
||||
{showDelayInput && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
已超过预计开始时间,请说明延后原因
|
||||
</div>
|
||||
<input value={delayReason} onChange={(e) => setDelayReason(e.target.value)} placeholder="延后原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleTransition('in_progress')} disabled={!delayReason.trim()} className="h-7 px-3 rounded text-[11px] font-medium bg-[var(--accent)] text-white disabled:opacity-50">开始开发</button>
|
||||
<button onClick={() => { setShowDelayInput(false); setDelayReason(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-[var(--line)]">
|
||||
{task.isBlocked ? (
|
||||
<div className="space-y-2">
|
||||
@@ -157,7 +197,52 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卡片3:基本信息 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" />时间信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">预计开始</span>
|
||||
<span className="text-[var(--ink)] font-medium tabular-nums">{task.expectedStartAt ? formatShortTime(task.expectedStartAt) : '-'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">预计截止</span>
|
||||
<span className="text-[var(--ink)] font-medium tabular-nums">{task.expectedEndAt ? formatShortTime(task.expectedEndAt) : '-'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">实际开始</span>
|
||||
<span className="text-[var(--ink)] font-medium tabular-nums">{task.actualStartAt ? formatShortTime(task.actualStartAt) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">实际结束</span>
|
||||
<span className="text-[var(--ink)] font-medium tabular-nums">{task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">预计耗时</span>
|
||||
<span className="text-[var(--ink)] font-medium tabular-nums">{formatHours(estimate)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">实际耗时</span>
|
||||
<span className={`font-medium tabular-nums ${actual > 0 ? (overrun ? 'text-red-600' : (actual < estimate ? 'text-emerald-600' : 'text-[var(--ink)]')) : 'text-[var(--ink-muted)]'}`}>
|
||||
{actual > 0 ? formatHours(actual) : '—'}
|
||||
{task.actualStartAt && !task.actualEndAt && <span className="text-[10px] ml-1 text-[var(--ink-muted)]">进行中</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{task.delayReason && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--line)] text-[11px] text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0 mt-0.5" />
|
||||
<div><span className="font-medium">延后原因:</span>{task.delayReason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{task.overdueVersionReason && (
|
||||
<div className="mt-2 text-[11px] text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||
<span className="font-medium">超出版本截止原因:</span>{task.overdueVersionReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<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]">
|
||||
@@ -177,35 +262,9 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[var(--ink-muted)]">预计</span>
|
||||
<span className="text-[var(--ink)] font-medium">{formatHours(task.estimateHours)}</span>
|
||||
<span className="text-[var(--ink-muted)]">创建</span>
|
||||
<span className="text-[var(--ink)]">{formatShortTime(task.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[var(--ink-muted)]">实际</span>
|
||||
<span className="text-[var(--ink)] font-medium">{task.startDate ? formatHours(calcActualHoursByDates(task.startDate, task.completedAt)) : '-'}</span>
|
||||
</div>
|
||||
{task.startDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[var(--ink-muted)]">开始开发</span>
|
||||
<span className="text-[var(--ink)]">{formatDateTime(task.startDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[var(--ink-muted)]">截止</span>
|
||||
<span className="text-[var(--ink)]">{task.dueDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.completedAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[var(--ink-muted)]">提测</span>
|
||||
<span className="text-emerald-600 font-medium">{formatDateTime(task.completedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{task.description && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--line)]">
|
||||
@@ -214,7 +273,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 卡片4:前置任务 */}
|
||||
{predecessors.length > 0 && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { formatHours, calcActualHoursByDates } from '@/lib/dev-task';
|
||||
import { getEstimateHours, getActualHours } from '@/lib/dev-task';
|
||||
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
|
||||
import type { DevTask } from '@/lib/dev-task';
|
||||
import type { TaskCategory } from '@/lib/task-category';
|
||||
|
||||
@@ -20,7 +21,33 @@ const PRIORITY_DOT: Record<string, string> = {
|
||||
P3: 'bg-zinc-300',
|
||||
};
|
||||
|
||||
function timeRangeText(task: DevTask): { text: string; tone: string } {
|
||||
if (task.status === 'submitted' && task.actualStartAt && task.actualEndAt) {
|
||||
return { text: `实际 ${formatShortTime(task.actualStartAt)} → ${formatShortTime(task.actualEndAt)}`, tone: 'text-[var(--ink-muted)]' };
|
||||
}
|
||||
if (task.actualStartAt) {
|
||||
return { text: `实际 ${formatShortTime(task.actualStartAt)} → 进行中`, tone: 'text-emerald-600' };
|
||||
}
|
||||
if (task.expectedStartAt && task.expectedEndAt) {
|
||||
return { text: `预计 ${formatShortTime(task.expectedStartAt)} → ${formatShortTime(task.expectedEndAt)}`, tone: 'text-blue-600' };
|
||||
}
|
||||
return { text: '', tone: 'text-[var(--ink-muted)]' };
|
||||
}
|
||||
|
||||
function hoursText(estimate: number, actual: number): { text: string; tone: string } {
|
||||
if (actual <= 0) return { text: `预 ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' };
|
||||
let tone = 'text-[var(--ink-soft)]';
|
||||
if (actual > estimate) tone = 'text-red-600';
|
||||
else if (actual < estimate) tone = 'text-emerald-600';
|
||||
return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone };
|
||||
}
|
||||
|
||||
export function DevTaskRow({ task, category, onClick }: Props) {
|
||||
const estimate = getEstimateHours(task);
|
||||
const actual = getActualHours(task);
|
||||
const range = timeRangeText(task);
|
||||
const hours = hoursText(estimate, actual);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@@ -38,12 +65,9 @@ export function DevTaskRow({ task, category, onClick }: Props) {
|
||||
</div>
|
||||
<CategoryChip category={category} />
|
||||
<StatusBadge status={task.status} />
|
||||
<span className="text-[11px] text-[var(--ink-muted)] w-24 text-right tabular-nums">
|
||||
{task.startDate && <span className="text-[var(--ink-soft)]">{calcActualHoursByDates(task.startDate, task.completedAt)}h</span>}
|
||||
{task.startDate && ' / '}
|
||||
{formatHours(task.estimateHours).split('(')[0]}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{task.assigneeId}</span>
|
||||
<span className={`text-[11px] tabular-nums shrink-0 ${range.tone}`} title={range.text}>{range.text}</span>
|
||||
<span className={`text-[11px] tabular-nums w-40 text-right shrink-0 whitespace-nowrap ${hours.tone}`}>{hours.text}</span>
|
||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{task.assigneeId}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { DevTaskRow } from './DevTaskRow';
|
||||
import { DevTaskCreateModal } from './DevTaskCreateModal';
|
||||
import { DevTaskDetailDrawer } from './DevTaskDetailDrawer';
|
||||
import { calcGroupProgress, DEV_TASK_STATUS_LABEL } from '@/lib/dev-task';
|
||||
import { calcGroupProgress, DEV_TASK_STATUS_LABEL, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
|
||||
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
||||
|
||||
interface Props {
|
||||
@@ -33,16 +36,18 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||
|
||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||
const versionTasks = useMemo(
|
||||
() => tasks.filter((t) => requirementIds.includes(t.requirementId)),
|
||||
[tasks, requirementIds],
|
||||
() => tasks.filter((t) => reqIdSet.has(t.requirementId)),
|
||||
[tasks, reqIdSet],
|
||||
);
|
||||
|
||||
// 筛选
|
||||
const [filterAssignee, setFilterAssignee] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterBlocked, setFilterBlocked] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = versionTasks;
|
||||
@@ -51,30 +56,30 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
if (filterBlocked === 'yes') result = result.filter((t) => t.isBlocked);
|
||||
if (filterBlocked === 'no') result = result.filter((t) => !t.isBlocked);
|
||||
if (filterCategory) result = result.filter((t) => t.categoryId === filterCategory);
|
||||
if (debouncedKeyword) result = result.filter((t) => matchTitleOrNo({ title: t.title, no: t.taskNo }, debouncedKeyword));
|
||||
return result;
|
||||
}, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory]);
|
||||
}, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory, debouncedKeyword]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20);
|
||||
|
||||
const progress = calcGroupProgress(versionTasks);
|
||||
const totalEstimate = versionTasks.reduce((s, t) => s + t.estimateHours, 0);
|
||||
const { estimate: totalEstimate } = aggregateDevTaskHours(versionTasks);
|
||||
const { calendarHours, manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]);
|
||||
const overrun = manhours > totalEstimate && totalEstimate > 0;
|
||||
const underrun = manhours > 0 && manhours < totalEstimate;
|
||||
const hoursTone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink-muted)]';
|
||||
const blockedCount = versionTasks.filter((t) => t.isBlocked).length;
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
|
||||
// 批量选择
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const isAllSelected = paged.length > 0 && paged.every((t) => selectedIds.has(t.id));
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((t) => t.id)));
|
||||
};
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return;
|
||||
@@ -92,25 +97,27 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
return map;
|
||||
}, [paged]);
|
||||
|
||||
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
|
||||
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
||||
const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]);
|
||||
|
||||
const assignees = useMemo(() => Array.from(new Set(versionTasks.map((t) => t.assigneeId))), [versionTasks]);
|
||||
const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory);
|
||||
const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory || debouncedKeyword);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 工具栏:进度 + 筛选 + 操作 合为一行 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||
{/* 进度 */}
|
||||
<Code2 className="h-3.5 w-3.5 text-[var(--accent)] shrink-0" />
|
||||
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{progress}%</span>
|
||||
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
|
||||
<div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{versionTasks.length}任务 · {totalEstimate}h</span>
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{versionTasks.length}任务 · 预 {formatWorkHours(totalEstimate)}{manhours > 0 ? <> · 日历 {formatWorkHours(calendarHours)} / 人力 <span className={`tabular-nums ${hoursTone}`}>{formatWorkHours(manhours)}</span></> : null}</span>
|
||||
{blockedCount > 0 && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{blockedCount}阻塞</span>}
|
||||
|
||||
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
|
||||
|
||||
{/* 筛选 */}
|
||||
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">负责人</option>
|
||||
{user?.name && <option value={user.name}>我的</option>}
|
||||
@@ -129,9 +136,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
<option value="">类型</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||
|
||||
{/* 右侧操作 */}
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
{selectedIds.size > 0 && (
|
||||
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||
@@ -144,7 +150,6 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的任务' : '暂无开发任务'}</p>
|
||||
@@ -152,7 +157,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
</div>
|
||||
) : (
|
||||
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
||||
const req = requirements.find((r) => r.id === reqId);
|
||||
const req = requirementMap.get(reqId);
|
||||
const reqProgress = calcGroupProgress(reqTasks);
|
||||
return (
|
||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
@@ -168,7 +173,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
<input type="checkbox" checked={selectedIds.has(t.id)} onChange={() => toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DevTaskRow task={t} category={categories.find((c) => c.id === t.categoryId)} onClick={() => setSelectedTaskId(t.id)} />
|
||||
<DevTaskRow task={t} category={categoryMap.get(t.categoryId)} onClick={() => setSelectedTaskId(t.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -180,7 +185,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||
|
||||
{showCreate && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
|
||||
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={versionTasks.map((t) => t.id)} onClose={() => setSelectedTaskId(null)} />}
|
||||
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} onClose={() => setSelectedTaskId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { getTaskWorklogs } from '@/lib/task-worklog';
|
||||
|
||||
@@ -13,7 +12,6 @@ interface Props {
|
||||
|
||||
export function WorklogPanel({ taskId }: Props) {
|
||||
const { worklogs, addWorklog, deleteWorklog } = useTaskWorklogStore();
|
||||
const { syncActualHours } = useDevTaskStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const taskLogs = getTaskWorklogs(worklogs, taskId);
|
||||
@@ -28,16 +26,13 @@ export function WorklogPanel({ taskId }: Props) {
|
||||
const handleAdd = () => {
|
||||
if (!workContent.trim() || hours <= 0) return;
|
||||
addWorklog({ taskId, userId: user?.name || '', date, hours, workContent: workContent.trim() });
|
||||
const newTotal = totalHours + hours;
|
||||
syncActualHours(taskId, newTotal);
|
||||
setWorkContent('');
|
||||
setHours(1);
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, logHours: number) => {
|
||||
const handleDelete = (id: string) => {
|
||||
deleteWorklog(id);
|
||||
syncActualHours(taskId, Math.max(0, totalHours - logHours));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -75,7 +70,7 @@ export function WorklogPanel({ taskId }: Props) {
|
||||
<p className="text-[12px] text-[var(--ink)]">{log.workContent}</p>
|
||||
<p className="text-[10px] text-[var(--ink-muted)]">{log.date} · {log.hours}h · {log.userId}</p>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(log.id, log.hours)} className="p-1 rounded hover:bg-red-50">
|
||||
<button onClick={() => handleDelete(log.id)} className="p-1 rounded hover:bg-red-50">
|
||||
<Trash2 className="h-3 w-3 text-[var(--ink-muted)] hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||
import { calcActualHoursByDates } from '@/lib/dev-task';
|
||||
import { getTestCaseActualHours } from '@/lib/test-case';
|
||||
import { formatWorkHours } from '@/lib/work-hours';
|
||||
import type { TestCase } from '@/lib/test-case';
|
||||
|
||||
interface Props {
|
||||
@@ -17,20 +19,23 @@ const PRIORITY_DOT: Record<string, string> = {
|
||||
P3: 'bg-zinc-300',
|
||||
};
|
||||
|
||||
export function TestCaseRow({ testCase, bugCount, onClick }: Props) {
|
||||
function TestCaseRowImpl({ testCase, bugCount, onClick }: Props) {
|
||||
const actualHours = getTestCaseActualHours(testCase);
|
||||
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">
|
||||
<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-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span>
|
||||
<TestCaseStatusBadge status={testCase.status} />
|
||||
{testCase.startedAt && (
|
||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-10 text-right">{calcActualHoursByDates(testCase.startedAt, testCase.completedAt)}h</span>
|
||||
{actualHours > 0 && (
|
||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
||||
)}
|
||||
{bugCount > 0 && (
|
||||
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">{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>
|
||||
)}
|
||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{testCase.assigneeId || '-'}</span>
|
||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{testCase.assigneeId || '-'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TestCaseRow = memo(TestCaseRowImpl);
|
||||
|
||||
@@ -11,8 +11,11 @@ import { TestCaseRow } from './TestCaseRow';
|
||||
import { TestCaseCreateModal } from './TestCaseCreateModal';
|
||||
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
|
||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||
import { calcTestProgress, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
|
||||
import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
||||
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { TestCaseStatus } from '@/lib/test-case';
|
||||
|
||||
interface Props {
|
||||
@@ -30,27 +33,33 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
|
||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||
const versionCases = useMemo(
|
||||
() => testCases.filter((c) => c.versionId === versionId),
|
||||
[testCases, versionId],
|
||||
);
|
||||
|
||||
// 提测状态
|
||||
const versionDevTasks = useMemo(() => devTasks.filter((t) => requirementIds.includes(t.requirementId)), [devTasks, requirementIds]);
|
||||
const versionDevTasks = useMemo(
|
||||
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
|
||||
[devTasks, reqIdSet],
|
||||
);
|
||||
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
|
||||
|
||||
const stats = calcTestProgress(versionCases);
|
||||
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
|
||||
|
||||
// 筛选
|
||||
const [filterAssignee, setFilterAssignee] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const filteredCases = useMemo(() => {
|
||||
let result = versionCases;
|
||||
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
|
||||
if (filterStatus) result = result.filter((c) => c.status === filterStatus);
|
||||
if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword));
|
||||
return result;
|
||||
}, [versionCases, filterAssignee, filterStatus]);
|
||||
}, [versionCases, filterAssignee, filterStatus, debouncedKeyword]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20);
|
||||
|
||||
@@ -58,18 +67,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
|
||||
|
||||
// 批量选择
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const isAllSelected = paged.length > 0 && paged.every((c) => selectedIds.has(c.id));
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((c) => c.id)));
|
||||
};
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
||||
@@ -79,7 +83,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
// 按需求分组
|
||||
const groupedByReq = useMemo(() => {
|
||||
const map = new Map<string, typeof paged>();
|
||||
for (const c of paged) {
|
||||
@@ -91,12 +94,18 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
return map;
|
||||
}, [paged]);
|
||||
|
||||
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
||||
const bugCountByCase = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
|
||||
return map;
|
||||
}, [bugs]);
|
||||
|
||||
const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]);
|
||||
const hasFilter = !!(filterAssignee || filterStatus);
|
||||
const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 提测通知 */}
|
||||
{allSubmitted && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50 border border-emerald-200">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||
@@ -110,7 +119,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏:进度 + 筛选 + 操作 一行 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span>
|
||||
@@ -119,9 +127,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</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.passRate}%</span>
|
||||
{tcManhours > 0 && (
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}</span>
|
||||
)}
|
||||
|
||||
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
|
||||
|
||||
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">负责人</option>
|
||||
{user?.name && <option value={user.name}>我的</option>}
|
||||
@@ -131,7 +143,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
<option value="">状态</option>
|
||||
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -145,7 +157,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{filteredCases.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
|
||||
@@ -153,7 +164,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
||||
const req = reqId === '__none__' ? null : requirements.find((r) => r.id === reqId);
|
||||
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
|
||||
const reqPassed = cases.filter((c) => c.status === 'passed').length;
|
||||
return (
|
||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
@@ -169,7 +180,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()} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<TestCaseRow testCase={c} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
|
||||
<TestCaseRow testCase={c} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user