Files
ftb-project-management/apps/web/components/version/PlanRequirementCoveragePanel.tsx
Script Generator 8947aeaac7 feat(工作台): 细化计划进展工时证据
关键改动:

- 支持需求覆盖和调研方向开始工作记录

- 日报按计划记录开始时间和进度下次开始时间计算证据

- 更新版本编辑校验、项目展示和 workflow 说明

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:02:38 +08:00

530 lines
25 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 { FilterSelect } from '@/components/FilterSelect';
import { formatDateTime } from '@/lib/format';
import type { Requirement } from '@/lib/requirement';
import type { PlanTask, RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
import {
canSaveRequirementCoverageDraft,
canOpenRequirementCoverageRecord,
getResearchDirectionProgressSummary,
getResearchDirectionStatus,
getRequirementCoverage,
getRequirementCoverageStatus,
getRequirementCoverageSummary,
REQUIREMENT_COVERAGE_LABEL,
startResearchDirectionWork,
startRequirementCoverageWork,
updateResearchDirectionProgress,
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 DirectionProps {
plan: VersionPlan;
canEdit: boolean;
currentUserName: string;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
}
interface LinkedRequirementReferenceProps {
requirements: RequirementOption[];
}
interface LogTimelineProps {
logs?: Array<VersionPlanLog | VersionPlanLogView>;
className?: string;
fillHeight?: boolean;
}
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 getLogTone(log: VersionPlanLog): { badge: string } {
if (log.aiStatus === 'error') {
return {
badge: 'border-red-200 bg-red-50 text-red-700',
};
}
if (log.type === 'ai_decompose') {
return {
badge: 'border-violet-200 bg-violet-50 text-violet-700',
};
}
if (log.coverageStatus === 'completed') {
return {
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
};
}
if (log.coverageStatus === 'partial') {
return {
badge: 'border-amber-200 bg-amber-50 text-amber-700',
};
}
return {
badge: 'border-zinc-200 bg-zinc-50 text-zinc-600',
};
}
function getLogTypeLabel(log: VersionPlanLog): string {
if (log.type === 'ai_decompose') return 'AI 拆解';
if (log.type === 'research_direction_progress') return '调研方向';
if (log.type === 'requirement_progress') return '需求进度';
return '系统记录';
}
function getLogMonthKey(createdAt: string): string | null {
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function getLogMonthLabel(monthKey: string): string {
const [year, month] = monthKey.split('-');
return `${year}${month}`;
}
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
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);
setCompletedContent(coverage?.completedContent ?? '');
setRemainingContent(coverage?.remainingContent ?? '');
};
const closeEditor = () => {
setEditingRequirementId(null);
setCompletedContent('');
setRemainingContent('');
};
const startCoverage = (req: RequirementOption) => {
if (!canEdit) return;
const patch = startRequirementCoverageWork(plan, {
requirementId: req.id,
updatedBy: currentUserName,
});
onUpdate(plan.id, patch);
};
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
const coverage = getRequirementCoverage(plan, req.id);
const workStartedAt = coverage?.currentWorkStartedAt;
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, workStartedAt)) return;
const patch = updateRequirementCoverage(plan, {
requirementId: req.id,
status,
completedContent: status === 'partial' ? completedContent : undefined,
remainingContent: status === '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="space-y-1 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;
const hasStartedWork = Boolean(coverage?.currentWorkStartedAt);
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, coverage?.currentWorkStartedAt);
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, coverage?.currentWorkStartedAt);
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 || coverage?.currentWorkStartedAt) && (
<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>}
{coverage.currentWorkStartedAt && <div className="text-blue-600">{formatDateTime(coverage.currentWorkStartedAt)}</div>}
</div>
)}
</div>
{canEdit && status !== 'completed' && (
<div className="flex shrink-0 items-center gap-1.5">
{canStartWork && (
<button
type="button"
onClick={() => startCoverage(req)}
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
>
</button>
)}
<button
type="button"
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(req))}
disabled={!canOpenRecord}
title={!hasStartedWork ? '请先开始任务' : undefined}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
>
{isEditing ? '收起' : '记录'}
</button>
</div>
)}
</div>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<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"
/>
<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 flex-wrap items-center gap-2">
<button
type="button"
onClick={() => saveCoverage(req, 'completed')}
disabled={!canSaveCompleted}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={closeEditor}
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
type="button"
onClick={() => saveCoverage(req, 'partial')}
disabled={!canSavePartial}
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>
</div>
);
}
export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onUpdate }: DirectionProps) {
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [completedContent, setCompletedContent] = useState('');
const [remainingContent, setRemainingContent] = useState('');
const tasks = plan.tasks ?? [];
const summary = getResearchDirectionProgressSummary(plan);
if (tasks.length === 0) return null;
const openEditor = (task: PlanTask) => {
setEditingTaskId(task.id);
setCompletedContent(task.completedContent ?? '');
setRemainingContent(task.remainingContent ?? '');
};
const closeEditor = () => {
setEditingTaskId(null);
setCompletedContent('');
setRemainingContent('');
};
const startDirection = (task: PlanTask) => {
if (!canEdit) return;
const patch = startResearchDirectionWork(plan, {
taskId: task.id,
updatedBy: currentUserName,
});
onUpdate(plan.id, patch);
};
const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, task.currentWorkStartedAt)) return;
const patch = updateResearchDirectionProgress(plan, {
taskId: task.id,
status,
completedContent: status === 'partial' ? completedContent : undefined,
remainingContent: status === 'partial' ? remainingContent : undefined,
updatedBy: currentUserName,
});
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="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
{tasks.map((task) => {
const status = getResearchDirectionStatus(task);
const isEditing = editingTaskId === task.id;
const hasStartedWork = Boolean(task.currentWorkStartedAt);
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, task.currentWorkStartedAt);
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, task.currentWorkStartedAt);
return (
<div key={task.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="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div>
{(task.completedContent || task.remainingContent || task.currentWorkStartedAt) && (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
{task.completedContent && <div className="line-clamp-2">{task.completedContent}</div>}
{task.remainingContent && <div className="line-clamp-2 text-amber-700">{task.remainingContent}</div>}
{task.currentWorkStartedAt && <div className="text-blue-600">{formatDateTime(task.currentWorkStartedAt)}</div>}
</div>
)}
</div>
{canEdit && status !== 'completed' && (
<div className="flex shrink-0 items-center gap-1.5">
{canStartWork && (
<button
type="button"
onClick={() => startDirection(task)}
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
>
</button>
)}
<button
type="button"
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(task))}
disabled={!canOpenRecord}
title={!hasStartedWork ? '请先开始任务' : undefined}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
>
{isEditing ? '收起' : '记录'}
</button>
</div>
)}
</div>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<textarea
value={completedContent}
onChange={(event) => setCompletedContent(event.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"
/>
<textarea
value={remainingContent}
onChange={(event) => setRemainingContent(event.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 flex-wrap items-center gap-2">
<button
type="button"
onClick={() => saveDirection(task, 'completed')}
disabled={!canSaveCompleted}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={closeEditor}
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
type="button"
onClick={() => saveDirection(task, 'partial')}
disabled={!canSavePartial}
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>
</div>
);
}
export function PlanLinkedRequirementReferenceList({ requirements }: LinkedRequirementReferenceProps) {
if (requirements.length === 0) return null;
return (
<div className="mt-3 space-y-2">
<div>
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]"></div>
</div>
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2">
{requirements.map((req) => (
<div key={req.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<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>
))}
</div>
</div>
);
}
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
const [selectedMonth, setSelectedMonth] = useState('all');
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const monthOptions = Array.from(new Set(sortedLogs.map((log) => getLogMonthKey(log.createdAt)).filter(Boolean) as string[]))
.sort((a, b) => b.localeCompare(a));
const effectiveMonth = selectedMonth === 'all' || monthOptions.includes(selectedMonth) ? selectedMonth : 'all';
const visibleLogs = effectiveMonth === 'all'
? sortedLogs
: sortedLogs.filter((log) => getLogMonthKey(log.createdAt) === effectiveMonth);
const frameClass = className || 'border-l border-[var(--line)] pl-4';
const listClass = fillHeight
? 'mt-3 min-h-0 flex-1 overflow-y-auto pr-1'
: 'mt-3 max-h-80 overflow-y-auto pr-1';
return (
<aside className={frameClass}>
<div className="shrink-0 border-b border-[var(--line)] pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[13px] font-semibold text-[var(--ink)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-muted)]"> AI </div>
</div>
<FilterSelect
value={effectiveMonth}
onChange={setSelectedMonth}
options={monthOptions.map((month) => ({ value: month, label: getLogMonthLabel(month) }))}
allLabel="全部月份"
/>
</div>
</div>
{visibleLogs.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
</div>
) : (
<div className={listClass}>
<div>
{visibleLogs.map((log) => {
const tone = getLogTone(log);
return (
<div key={log.id} className="border-b border-[var(--line)] py-3 first:pt-0 last:border-b-0 last:pb-0">
<div className="min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-semibold ${tone.badge}`}>
{getLogTypeLabel(log)}
</span>
<span className="min-w-0 truncate text-[11px] text-[var(--ink-muted)]">{log.actor}</span>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
</div>
<div className="mt-1.5 text-[12px] font-semibold leading-5 text-[var(--ink)]">{log.title}</div>
{'planTitle' in log && log.planTitle && (
<div className="mt-1 truncate text-[11px] text-[var(--ink-soft)]" title={log.planTitle}>
{log.planTitle}
</div>
)}
{log.detail && (
<div className="mt-2 border-l-2 border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-[11px] leading-5 text-[var(--ink-soft)]">
<div className="whitespace-pre-wrap">{log.detail}</div>
</div>
)}
{log.workStartedAt && (
<div className="mt-2 inline-flex rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700">
{formatDateTime(log.workStartedAt)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</aside>
);
}