Files
ftb-project-management/apps/web/components/version/PlanRequirementCoveragePanel.tsx
2026-06-30 11:00:07 +08:00

234 lines
11 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 { useState } from 'react';
import { Check, Clock3, Sparkles } from 'lucide-react';
import { formatDateTime } from '@/lib/format';
import type { Requirement } from '@/lib/requirement';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan';
import {
getRequirementCoverage,
getRequirementCoverageStatus,
getRequirementCoverageSummary,
REQUIREMENT_COVERAGE_LABEL,
updateRequirementCoverage,
} from '@/lib/version-plan';
type RequirementOption = Pick<Requirement, 'id' | 'code' | 'title'> & { isHistorical?: boolean };
interface CoverageProps {
plan: VersionPlan;
requirements: RequirementOption[];
canEdit: boolean;
currentUserName: string;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
}
interface LogTimelineProps {
logs?: VersionPlanLog[];
className?: string;
}
const COVERAGE_STATUS_OPTIONS: RequirementCoverageStatus[] = ['partial', 'completed', 'not_started'];
const COVERAGE_BADGE_STYLE: Record<RequirementCoverageStatus, string> = {
not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500',
partial: 'border-amber-200 bg-amber-50 text-amber-700',
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
};
function getLogIcon(log: VersionPlanLog) {
if (log.type === 'ai_decompose') return <Sparkles className="h-3.5 w-3.5" />;
if (log.type === 'requirement_progress') return <Check className="h-3.5 w-3.5" />;
return <Clock3 className="h-3.5 w-3.5" />;
}
function getLogTone(log: VersionPlanLog): string {
if (log.aiStatus === 'error') return 'bg-red-50 text-red-700 ring-red-100';
if (log.type === 'ai_decompose') return 'bg-purple-50 text-purple-700 ring-purple-100';
if (log.coverageStatus === 'completed') return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
if (log.coverageStatus === 'partial') return 'bg-amber-50 text-amber-700 ring-amber-100';
return 'bg-zinc-50 text-zinc-600 ring-zinc-100';
}
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
const [draftStatus, setDraftStatus] = useState<RequirementCoverageStatus>('partial');
const [completedContent, setCompletedContent] = useState('');
const [remainingContent, setRemainingContent] = useState('');
const summary = getRequirementCoverageSummary(plan);
if (requirements.length === 0) return null;
const openEditor = (req: RequirementOption) => {
const coverage = getRequirementCoverage(plan, req.id);
setEditingRequirementId(req.id);
setDraftStatus(coverage?.status === 'completed' ? 'completed' : coverage?.status === 'not_started' ? 'not_started' : 'partial');
setCompletedContent(coverage?.completedContent ?? '');
setRemainingContent(coverage?.remainingContent ?? '');
};
const closeEditor = () => {
setEditingRequirementId(null);
setCompletedContent('');
setRemainingContent('');
setDraftStatus('partial');
};
const canSave = draftStatus === 'not_started'
|| (draftStatus === 'completed' && completedContent.trim().length > 0)
|| (draftStatus === 'partial' && completedContent.trim().length > 0 && remainingContent.trim().length > 0);
const saveCoverage = (req: RequirementOption) => {
if (!canEdit || !canSave) return;
const patch = updateRequirementCoverage(plan, {
requirementId: req.id,
status: draftStatus,
completedContent: draftStatus === 'not_started' ? undefined : completedContent,
remainingContent: draftStatus === 'partial' ? remainingContent : undefined,
updatedBy: currentUserName,
requirementCode: req.code,
requirementTitle: req.title,
});
onUpdate(plan.id, patch);
closeEditor();
};
return (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
{summary.completed} / {summary.total}
{summary.partial > 0 && <span className="ml-2 text-amber-700"> {summary.partial}</span>}
</div>
</div>
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{summary.percent}%</span>
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
</div>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
{requirements.map((req) => {
const status = getRequirementCoverageStatus(plan, req.id);
const coverage = getRequirementCoverage(plan, req.id);
const isEditing = editingRequirementId === req.id;
return (
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<div className="flex min-w-0 items-start gap-2">
<span className={`mt-0.5 inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${COVERAGE_BADGE_STYLE[status]}`}>
{REQUIREMENT_COVERAGE_LABEL[status]}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[11px] text-[var(--ink-muted)]">{req.code}</span>
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600"></span>}
</div>
{(coverage?.completedContent || coverage?.remainingContent) && (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
{coverage.completedContent && <div className="line-clamp-2">{coverage.completedContent}</div>}
{coverage.remainingContent && <div className="line-clamp-2 text-amber-700">{coverage.remainingContent}</div>}
</div>
)}
</div>
{canEdit && (
<button
type="button"
onClick={() => isEditing ? closeEditor() : openEditor(req)}
className="shrink-0 rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
{isEditing ? '收起' : '记录'}
</button>
)}
</div>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<div className="grid grid-cols-3 gap-1.5">
{COVERAGE_STATUS_OPTIONS.map((statusOption) => (
<button
key={statusOption}
type="button"
onClick={() => setDraftStatus(statusOption)}
className={`h-7 rounded-md border text-[11px] font-medium transition-colors ${draftStatus === statusOption ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{REQUIREMENT_COVERAGE_LABEL[statusOption]}
</button>
))}
</div>
{draftStatus !== 'not_started' && (
<textarea
value={completedContent}
onChange={(e) => setCompletedContent(e.target.value)}
rows={2}
placeholder="本次已完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
)}
{draftStatus === 'partial' && (
<textarea
value={remainingContent}
onChange={(e) => setRemainingContent(e.target.value)}
rows={2}
placeholder="剩余未完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={closeEditor} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
<button
type="button"
onClick={() => saveCoverage(req)}
disabled={!canSave}
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
</button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export function PlanLogTimeline({ logs, className = '' }: LogTimelineProps) {
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const frameClass = className || 'border-l border-[var(--line)] pl-4';
return (
<aside className={frameClass}>
<div className="flex items-center justify-between">
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<span className="text-[11px] tabular-nums text-[var(--ink-soft)]">{sortedLogs.length}</span>
</div>
{sortedLogs.length === 0 ? (
<div className="mt-4 rounded-lg bg-[var(--bg-subtle)] px-3 py-4 text-center text-[11px] text-[var(--ink-muted)]"></div>
) : (
<div className="mt-3 max-h-80 space-y-3 overflow-y-auto pr-1">
{sortedLogs.map((log) => (
<div key={log.id} className="relative pl-5">
<span className={`absolute left-0 top-0 flex h-6 w-6 -translate-x-3 items-center justify-center rounded-full ring-4 ${getLogTone(log)}`}>
{getLogIcon(log)}
</span>
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[12px] font-medium leading-5 text-[var(--ink)]">{log.title}</div>
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
</div>
<div className="text-[11px] text-[var(--ink-muted)]">{log.actor}</div>
{log.detail && <div className="whitespace-pre-wrap rounded-md bg-[var(--bg-subtle)] px-2 py-1.5 text-[11px] leading-4 text-[var(--ink-soft)]">{log.detail}</div>}
</div>
</div>
))}
</div>
)}
</aside>
);
}