261 lines
15 KiB
TypeScript
261 lines
15 KiB
TypeScript
'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';
|
||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||
|
||
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 = getProjectAdoptedRequirementCandidates(requirements, projectId).filter((r) => !r.versionId);
|
||
|
||
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>
|
||
{filtered.length > 0 && (
|
||
<div className="mt-2 flex items-center justify-between">
|
||
<span className="text-[11px] text-[var(--ink-muted)]">当前筛选 {filtered.length} 条,已选 {selected.size} 条</span>
|
||
<div className="flex gap-2">
|
||
<button type="button" onClick={() => setSelected(new Set(filtered.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline">全选</button>
|
||
<button type="button" onClick={() => setSelected(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline">清空</button>
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|