Files
ftb-project-management/apps/web/components/version/VersionRequirementsTab.tsx
Script Generator 700c8a9aba feat: 计划转交功能 + 关联需求增强(描述/变更/类型/日期)
一、调研/产品方案/UI设计 - 转交功能:
- 未开始/进行中状态可转交给其他参与人员
- 转交按钮打开内联选择器,从 version.members 选人
- 确认后更新 plan.owner

二、关联需求表格增强:
- 描述列:超出宽度省略号,hover 显示完整 title 属性
- 需求类型列:原始需求(灰色)/ 变更需求(橙色)
- 变更原因列:显示变更原因标签
- 添加日期列:显示 createdAt

三、需求变更功能:
- 新增"需求变更"按钮(橙色边框)
- 变更表单:变更人员(从参与人员选)、变更原因(6种)、
  需求概述、需求详细
- 变更原因选项:需求理解偏差导致返工、反馈导致返工、
  实现难度超预期、上游交付延误、范围蔓延、其他
- 创建后自动关联到当前版本,标记为变更需求

四、数据模型扩展(lib/requirement.ts):
- 新增 reqType: 'original' | 'change'
- 新增 changeReason: ChangeReason
- 新增 changeBy: string
- 导出 CHANGE_REASON_LABEL 常量

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 16:21:14 +08:00

251 lines
15 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 { Plus, X, Search } from 'lucide-react';
import type { Requirement, ChangeReason } from '@/lib/requirement';
import type { DevTask } from '@/lib/dev-task';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
interface Props {
versionId: string;
projectId: string;
requirements: Requirement[];
devTasks: DevTask[];
versionMembers: { role: string; name: string }[];
onLink: (reqIds: string[], addedBy: string) => void;
onUnlink: (reqId: string) => void;
onCreateChange: (data: Partial<Requirement>) => void;
currentUserName: string;
}
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName }: Props) {
const [showAddModal, setShowAddModal] = useState(false);
const [showChangeModal, setShowChangeModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} </span>
<div className="flex items-center gap-2">
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button onClick={() => setShowAddModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
</div>
{linkedReqs.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
</div>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right"></th>
</tr>
</thead>
<tbody>
{linkedReqs.map((req) => {
const devStatus = deriveReqDevStatus(req.id, devTasks);
const isDeveloping = !canEditRequirement(req.id, devTasks);
return (
<tr key={req.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
<td className="px-4 py-3 font-mono text-[12px] text-[var(--ink-muted)]">{req.code}</td>
<td className="px-4 py-3 font-medium text-[var(--ink)]">{req.title}</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)] max-w-[180px]">
<span className="block truncate" title={req.description || ''}>{req.description || '-'}</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${req.reqType === 'change' ? 'bg-orange-50 text-orange-600' : 'bg-zinc-100 text-zinc-600'}`}>
{req.reqType === 'change' ? '变更需求' : '原始需求'}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{req.changeReason ? CHANGE_REASON_LABEL[req.changeReason] : '-'}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
{REQ_DEV_STATUS_LABEL[devStatus]}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '系统添加'}</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.createdAt?.slice(0, 10) || '-'}</td>
<td className="px-4 py-3 text-right">
{isDeveloping ? (
<span className="text-[11px] text-[var(--ink-muted)]"></span>
) : (
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"></button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAddModal && (
<AddRequirementModal
available={availableReqs}
onClose={() => setShowAddModal(false)}
onConfirm={(ids) => { onLink(ids, currentUserName); setShowAddModal(false); }}
/>
)}
{showChangeModal && (
<ChangeRequirementModal
versionMembers={versionMembers}
currentUserName={currentUserName}
onClose={() => setShowChangeModal(false)}
onSubmit={(data) => { onCreateChange(data); setShowChangeModal(false); }}
/>
)}
</div>
);
}
function ChangeRequirementModal({ versionMembers, currentUserName, onClose, onSubmit }: {
versionMembers: { role: string; name: string }[];
currentUserName: string;
onClose: () => void;
onSubmit: (data: Partial<Requirement>) => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [changeBy, setChangeBy] = useState(currentUserName);
const [changeReason, setChangeReason] = useState<ChangeReason | ''>('');
const handleSubmit = () => {
if (!title.trim() || !changeReason || !changeBy) return;
onSubmit({
title: title.trim(),
description: description.trim(),
changeBy,
changeReason: changeReason as ChangeReason,
reqType: 'change',
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<select value={changeBy} onChange={(e) => setChangeBy(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
{versionMembers.map((m) => <option key={`${m.role}-${m.name}`} value={m.name}>{m.name}</option>)}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<select value={changeReason} onChange={(e) => setChangeReason(e.target.value as ChangeReason)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{Object.entries(CHANGE_REASON_LABEL).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="变更需求标题" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} placeholder="变更需求详细描述" className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={handleSubmit} disabled={!title.trim() || !changeReason} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}
function AddRequirementModal({ available, onClose, onConfirm }: {
available: Requirement[];
onClose: () => void;
onConfirm: (ids: string[]) => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const filtered = available.filter((r) =>
r.title.includes(search) || r.code.includes(search)
);
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
setSelected(next);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-3 border-b border-[var(--line)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (
<p className="text-center text-[12px] text-[var(--ink-muted)] py-8"></p>
) : (
<div className="space-y-1.5">
{filtered.map((req) => (
<label key={req.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-[var(--bg-subtle)] cursor-pointer">
<input type="checkbox" checked={selected.has(req.id)} onChange={() => toggle(req.id)} className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)]" />
<span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{req.code}</span>
<span className="text-[12px] text-[var(--ink)] flex-1 truncate">{req.title}</span>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--line)]">
<span className="text-[11px] text-[var(--ink-muted)]"> {selected.size} </span>
<div className="flex gap-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onConfirm(Array.from(selected))} disabled={selected.size === 0} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}