Files
ftb-project-management/apps/web/components/version/PlanDetailDrawer.tsx
Script Generator 3d3d56697a feat(版本): 完善研发计划与预警已读
关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 16:48:58 +08:00

411 lines
21 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 { X, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import {
getResearchDirectionProgressSummary,
getRequirementCoverageSummary,
PRODUCT_PLAN_KIND_LABEL,
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
} from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
interface Props {
planId: string;
onClose: () => void;
contextLabel?: string;
}
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
const STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
const TYPE_LABEL: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
function getProductPlanKind(plan: VersionPlan): ProductPlanKind {
return plan.productPlanKind ?? 'design';
}
function getSubmitActionLabel(plan: VersionPlan): string {
if (plan.type === 'product') {
return getProductPlanKind(plan) === 'review' ? '提交评审结论' : '提交原型地址';
}
return '提交成果';
}
function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
if (!types?.length) return [];
return types
.map((type) => PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.find((option) => option.value === type)?.label)
.filter(Boolean) as string[];
}
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const { plans, updatePlan, completePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [resultUrl, setResultUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
const [reviewResult, setReviewResult] = useState<ProductPlanReviewResult>('passed');
const [reviewFailureTypes, setReviewFailureTypes] = useState<Set<ProductPlanReviewFailureType>>(new Set());
const [reviewFailureReason, setReviewFailureReason] = useState('');
const [showComplete, setShowComplete] = useState(false);
const plan = plans.find((p) => p.id === planId);
if (!plan) return null;
const completionState = getPlanCompletionState(plan);
const progress = plan.type === 'research'
? getResearchDirectionProgressSummary(plan).percent
: getRequirementCoverageSummary(plan).percent;
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const currentUserName = user?.name ?? plan.owner;
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
const isProductDesignPlan = productPlanKind === 'design';
const isProductReviewPlan = productPlanKind === 'review';
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileData(reader.result as string);
reader.readAsDataURL(file);
};
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
const next = new Set(reviewFailureTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
setReviewFailureTypes(next);
};
const handleSubmitResult = () => {
let payload: PlanResultPayload | null = null;
if (isProductReviewPlan) {
payload = {
productPlanKind: 'review',
reviewResult,
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
};
} else if (isProductDesignPlan) {
const title = resultTitle.trim();
const url = resultUrl.trim();
if (!url || !title || !prototypeReviewConfirmed) return;
payload = {
productPlanKind: 'design',
resultType: 'link',
resultTitle: title,
resultUrl: url,
prototypeReviewConfirmed,
};
} else {
const url = resultType === 'link' ? resultUrl.trim() : fileData;
const title = resultTitle.trim();
if (!url || !title) return;
payload = {
resultType,
resultTitle: title,
resultUrl: url,
resultFileName: fileName || undefined,
resultFileData: resultType === 'file' ? fileData : undefined,
};
}
const response = completePlan(plan.id, payload);
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
alert(response.message || '计划未满足完成条件');
return;
}
setShowComplete(false);
};
const handleTransfer = () => {
if (!transferTo) return;
updatePlan(plan.id, { owner: transferTo });
setShowTransfer(false);
setTransferTo('');
};
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 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Context */}
{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>
)}
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)] shrink-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</span>
{plan.type === 'product' && (
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
)}
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
{plan.type === 'product' && plan.reviewResult && (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}</span>
)}
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<h3 className="text-[15px] font-semibold text-[var(--ink)]">{plan.title}</h3>
{/* Info */}
<div className="grid grid-cols-2 gap-3 text-[12px]">
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.owner}</div>
</div>
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{progress}%</div>
</div>
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</div>
</div>
{plan.actualStartAt && (
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-blue-600 mt-0.5">{formatDateTime(plan.actualStartAt)}</div>
</div>
)}
{plan.completedAt && (
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-emerald-600 mt-0.5">{formatDateTime(plan.completedAt)}</div>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-2 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
{plan.type === 'research' && (
<PlanResearchDirectionPanel
plan={plan}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
)}
{linkedReqs.length > 0 && (
<div>
{plan.type === 'research' ? (
<PlanLinkedRequirementReferenceList requirements={linkedReqs} />
) : (
<PlanRequirementCoveragePanel
plan={plan}
requirements={linkedReqs}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
)}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
</div>
)}
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
{/* Result */}
{plan.status === 'completed' && plan.resultUrl && (
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
<div className="text-[11px] text-[var(--ink-muted)] mb-1"></div>
<div className="flex items-center gap-1.5">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
)}
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
<div className={`rounded-lg border p-3 ${plan.reviewResult === 'passed' ? 'border-emerald-200 bg-emerald-50' : 'border-red-200 bg-red-50'}`}>
<div className={`text-[12px] font-medium ${plan.reviewResult === 'passed' ? 'text-emerald-700' : 'text-red-700'}`}>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
</div>
{plan.reviewResult === 'failed' && (
<div className="mt-2 space-y-1 text-[12px] text-red-700">
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
{plan.reviewFailureReason && <div>{plan.reviewFailureReason}</div>}
</div>
)}
</div>
)}
{plan.remark && (
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
<div className="text-[11px] text-[var(--ink-muted)] mb-1"></div>
<div className="text-[12px] text-[var(--ink)]">{plan.remark}</div>
</div>
)}
{/* Transfer Section */}
{showTransfer && (
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={members.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择人员"
/>
<div className="flex gap-2">
<button onClick={handleTransfer} disabled={!transferTo} className="h-7 px-3 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
)}
{/* Complete with result */}
{showComplete && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
<div className="text-[11px] font-medium text-emerald-700">{getSubmitActionLabel(plan)}</div>
{isProductReviewPlan ? (
<>
<div className="grid grid-cols-2 gap-2">
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
<button
key={result}
onClick={() => setReviewResult(result)}
className={`h-8 rounded-lg border text-[11px] font-medium ${reviewResult === result ? 'border-[var(--accent)] bg-white text-[var(--accent)]' : 'border-[var(--line)] bg-white/70 text-[var(--ink-soft)]'}`}
>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
</button>
))}
</div>
{reviewResult === 'failed' && (
<>
<div className="grid grid-cols-2 gap-1.5">
{PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
<label key={option.value} className="flex min-h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--line)] bg-white/70 px-2 py-1 text-[11px] text-[var(--ink-soft)]">
<input type="checkbox" checked={reviewFailureTypes.has(option.value)} onChange={() => toggleFailureType(option.value)} className="h-3 w-3 shrink-0 rounded" />
<span className="leading-4">{option.label}</span>
</label>
))}
</div>
<textarea
value={reviewFailureReason}
onChange={(e) => setReviewFailureReason(e.target.value)}
rows={3}
placeholder="写清楚具体问题、影响范围和建议调整方向"
className="w-full rounded-lg border border-[var(--line)] bg-white px-2 py-2 text-[12px] focus:border-[var(--accent)] focus:outline-none resize-none"
/>
</>
)}
</>
) : (
<>
{isProductDesignPlan && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
Axure
</div>
)}
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '成果标题(如 v1.0 原型地址)' : '成果标题(必填,如 v1.0 产品方案)'} className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
{!isProductDesignPlan && (
<div className="flex gap-2">
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button>
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button>
</div>
)}
{resultType === 'link' || isProductDesignPlan ? (
<input value={resultUrl} onChange={(e) => setResultUrl(e.target.value)} placeholder="https://..." className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[11px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[10px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
{isProductDesignPlan && (
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-white/70 px-3 py-2 text-[11px] text-[var(--ink-soft)]">
<input
type="checkbox"
checked={prototypeReviewConfirmed}
onChange={(e) => setPrototypeReviewConfirmed(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 rounded"
/>
<span></span>
</label>
)}
</>
)}
<div className="flex gap-2">
<button
onClick={handleSubmitResult}
disabled={
!completionState.canSubmitResult
|| (isProductReviewPlan
? reviewResult === 'failed' && (reviewFailureTypes.size === 0 || !reviewFailureReason.trim())
: isProductDesignPlan
? !resultTitle.trim() || !resultUrl.trim() || !prototypeReviewConfirmed
: !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData))
}
className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"
>
</button>
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
)}
</div>
{/* Footer Actions */}
{plan.status !== 'completed' && (
<>
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && (
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
<Play className="h-3 w-3" />
</button>
)}
{plan.status === 'in_progress' && (
<button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
{getSubmitActionLabel(plan)}
</button>
)}
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
<ArrowRightLeft className="h-3 w-3" />
</button>
</div>
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</div>
)}
</>
)}
</div>
</div>
);
}