feat(需求池): 调整需求池布局与详情展示
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown, ChevronDown, ChevronRight, FolderOpen, Layers } from 'lucide-react';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
@@ -10,7 +10,8 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
|||||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||||
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||||
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||||
|
import { buildRequirementScopeTree, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||||
@@ -30,9 +31,8 @@ const STATUS_TABS: { key: string; label: string }[] = [
|
|||||||
{ key: 'pending_review', label: '待评审' },
|
{ key: 'pending_review', label: '待评审' },
|
||||||
{ key: 'adopted', label: '已采纳' },
|
{ key: 'adopted', label: '已采纳' },
|
||||||
{ key: 'rejected', label: '已拒绝' },
|
{ key: 'rejected', label: '已拒绝' },
|
||||||
{ key: 'planned', label: '已规划' },
|
|
||||||
{ key: 'developing', label: '开发中' },
|
{ key: 'developing', label: '开发中' },
|
||||||
{ key: 'testing', label: '测试中' },
|
{ key: 'dev_completed', label: '已完成' },
|
||||||
{ key: 'released', label: '已上线' },
|
{ key: 'released', label: '已上线' },
|
||||||
{ key: 'closed', label: '已关闭' },
|
{ key: 'closed', label: '已关闭' },
|
||||||
];
|
];
|
||||||
@@ -45,6 +45,117 @@ export default function RequirementsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RequirementProductNode({
|
||||||
|
product,
|
||||||
|
selectedScope,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
product: RequirementProductScopeNode;
|
||||||
|
selectedScope: RequirementScopeSelection;
|
||||||
|
onSelect: (scope: RequirementScopeSelection) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const active = selectedScope.type === 'product' && selectedScope.productId === product.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-0.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
className="flex h-7 w-6 shrink-0 items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||||
|
aria-label={expanded ? '收起产品' : '展开产品'}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect({ type: 'product', productId: product.id })}
|
||||||
|
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
|
||||||
|
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">{product.name}</span>
|
||||||
|
<ScopeSignals count={product.count} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-7 mt-0.5 space-y-0.5">
|
||||||
|
{product.projects.map((project) => (
|
||||||
|
<RequirementProjectNode
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
selectedScope={selectedScope}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequirementProjectNode({
|
||||||
|
project,
|
||||||
|
selectedScope,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
project: RequirementProductScopeNode['projects'][number];
|
||||||
|
selectedScope: RequirementScopeSelection;
|
||||||
|
onSelect: (scope: RequirementScopeSelection) => void;
|
||||||
|
}) {
|
||||||
|
const active = selectedScope.type === 'project' && selectedScope.projectId === project.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect({ type: 'project', projectId: project.id })}
|
||||||
|
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
|
||||||
|
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-[var(--ink-muted)]" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">{project.name}</span>
|
||||||
|
<ScopeSignals count={project.count} compact />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeSignals({ count, compact = false }: { count: RequirementProductScopeNode['count']; compact?: boolean }) {
|
||||||
|
const pendingReview = count.byStatus.pending_review ?? 0;
|
||||||
|
const active = (count.byStatus.developing ?? 0) + (count.byStatus.testing ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
{!compact && <ScopeSignal title="待评审" label="评" count={pendingReview} tone="amber" />}
|
||||||
|
{!compact && <ScopeSignal title="执行中" label="进" count={active} tone="blue" />}
|
||||||
|
<ScopeCount count={count.total} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeSignal({ title, label, count, tone }: { title: string; label: string; count: number; tone: 'amber' | 'blue' }) {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
const toneClass = tone === 'amber' ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700';
|
||||||
|
return (
|
||||||
|
<span title={title} className={`rounded px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${toneClass}`}>
|
||||||
|
{label}{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeCount({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<span className="rounded bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)]">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RequirementsPageContent() {
|
function RequirementsPageContent() {
|
||||||
const {
|
const {
|
||||||
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
|
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
|
||||||
@@ -60,9 +171,9 @@ function RequirementsPageContent() {
|
|||||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
|
const [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [projectFilter, setProjectFilter] = useState('all');
|
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
const [typeFilter, setTypeFilter] = useState('all');
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
const [versionFilter, setVersionFilter] = useState('all');
|
const [versionFilter, setVersionFilter] = useState('all');
|
||||||
@@ -79,6 +190,43 @@ function RequirementsPageContent() {
|
|||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||||
|
|
||||||
|
const selectedScopeKey = selectedScope.type === 'all'
|
||||||
|
? 'all'
|
||||||
|
: selectedScope.type === 'product'
|
||||||
|
? `product:${selectedScope.productId}`
|
||||||
|
: `project:${selectedScope.projectId}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVersionFilter('all');
|
||||||
|
}, [selectedScopeKey]);
|
||||||
|
|
||||||
|
const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
|
||||||
|
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
||||||
|
const scopedVersions = useMemo(() => {
|
||||||
|
if (selectedScope.type === 'product') return allVersions.filter((v) => v.productId === selectedScope.productId);
|
||||||
|
if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId);
|
||||||
|
return allVersions;
|
||||||
|
}, [allVersions, selectedScope]);
|
||||||
|
|
||||||
|
const selectedScopeTitle = useMemo(() => {
|
||||||
|
if (selectedScope.type === 'product') {
|
||||||
|
return overview.find((product) => product.id === selectedScope.productId)?.name ?? '产品需求';
|
||||||
|
}
|
||||||
|
if (selectedScope.type === 'project') {
|
||||||
|
return allProjects.find((project) => project.id === selectedScope.projectId)?.name ?? '项目需求';
|
||||||
|
}
|
||||||
|
return '全部需求';
|
||||||
|
}, [allProjects, overview, selectedScope]);
|
||||||
|
|
||||||
|
const selectedScopeMeta = useMemo(() => {
|
||||||
|
if (selectedScope.type === 'product') return '产品下全部项目';
|
||||||
|
if (selectedScope.type === 'project') {
|
||||||
|
const project = allProjects.find((item) => item.id === selectedScope.projectId);
|
||||||
|
return project ? `${project.productName} / 项目` : '项目';
|
||||||
|
}
|
||||||
|
return '所有产品与项目';
|
||||||
|
}, [allProjects, selectedScope]);
|
||||||
|
|
||||||
// Resolve version name from overview
|
// Resolve version name from overview
|
||||||
const resolveVersionName = (versionId?: string): string => {
|
const resolveVersionName = (versionId?: string): string => {
|
||||||
if (!versionId) return '-';
|
if (!versionId) return '-';
|
||||||
@@ -90,7 +238,7 @@ function RequirementsPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = [...requirements];
|
let list = [...scopedRequirements];
|
||||||
|
|
||||||
// search filter
|
// search filter
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -102,12 +250,9 @@ function RequirementsPageContent() {
|
|||||||
|
|
||||||
// status filter
|
// status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
list = list.filter((r) => r.status === statusFilter);
|
list = statusFilter === 'dev_completed'
|
||||||
}
|
? list.filter((r) => deriveReqDevStatus(r.id, devTasks) === 'completed')
|
||||||
|
: list.filter((r) => r.status === statusFilter);
|
||||||
// project filter
|
|
||||||
if (projectFilter !== 'all') {
|
|
||||||
list = list.filter((r) => r.projectId === projectFilter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// priority filter
|
// priority filter
|
||||||
@@ -130,7 +275,7 @@ function RequirementsPageContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, versionFilter, dateSort]);
|
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||||
|
|
||||||
@@ -150,15 +295,53 @@ function RequirementsPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full overflow-hidden bg-[var(--bg)]">
|
||||||
{/* Header */}
|
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div>
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">需求</h1>
|
<h1 className="text-[14px] font-semibold text-[var(--ink)]">需求池</h1>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">按产品 / 项目查看</p>
|
||||||
|
</div>
|
||||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||||
{requirements.length}
|
{requirements.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedScope({ type: 'all' })}
|
||||||
|
className={`mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${
|
||||||
|
selectedScope.type === 'all'
|
||||||
|
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
|
||||||
|
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 text-left">全部需求</span>
|
||||||
|
<ScopeCount count={requirements.length} />
|
||||||
|
</button>
|
||||||
|
{scopeTree.map((product) => (
|
||||||
|
<RequirementProductNode
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
selectedScope={selectedScope}
|
||||||
|
onSelect={setSelectedScope}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h2 className="truncate text-[15px] font-semibold tracking-tight text-[var(--ink)]">{selectedScopeTitle}</h2>
|
||||||
|
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||||
|
{scopedRequirements.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDrawerType('source')}
|
onClick={() => setDrawerType('source')}
|
||||||
@@ -206,15 +389,6 @@ function RequirementsPageContent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project dropdown */}
|
|
||||||
<FilterSelect
|
|
||||||
value={projectFilter}
|
|
||||||
onChange={setProjectFilter}
|
|
||||||
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
|
|
||||||
placeholder="全部项目"
|
|
||||||
allLabel="全部项目"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Priority dropdown */}
|
{/* Priority dropdown */}
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={priorityFilter}
|
value={priorityFilter}
|
||||||
@@ -237,7 +411,7 @@ function RequirementsPageContent() {
|
|||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={versionFilter}
|
value={versionFilter}
|
||||||
onChange={setVersionFilter}
|
onChange={setVersionFilter}
|
||||||
options={allVersions.map((v) => ({ value: v.id, label: v.name }))}
|
options={scopedVersions.map((v) => ({ value: v.id, label: v.name }))}
|
||||||
placeholder="全部版本"
|
placeholder="全部版本"
|
||||||
allLabel="全部版本"
|
allLabel="全部版本"
|
||||||
/>
|
/>
|
||||||
@@ -253,8 +427,8 @@ function RequirementsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
<div className="overflow-x-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
<table className="w-full text-left text-[13px]">
|
<table className="min-w-[1120px] w-full text-left text-[13px]">
|
||||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||||
<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>
|
||||||
@@ -382,7 +556,7 @@ function RequirementsPageContent() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* 已采纳/已规划:关闭 */}
|
{/* 已采纳/已规划:关闭 */}
|
||||||
{(req.status === 'adopted' || req.status === 'planned') && (
|
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
||||||
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
|
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
|
||||||
@@ -411,6 +585,7 @@ function RequirementsPageContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* Detail drawer */}
|
{/* Detail drawer */}
|
||||||
{viewingReq && (
|
{viewingReq && (
|
||||||
@@ -420,12 +595,13 @@ function RequirementsPageContent() {
|
|||||||
types={types}
|
types={types}
|
||||||
platforms={platforms}
|
platforms={platforms}
|
||||||
sourceTargets={sourceTargets}
|
sourceTargets={sourceTargets}
|
||||||
|
requirements={requirements}
|
||||||
resolveVersionName={resolveVersionName}
|
resolveVersionName={resolveVersionName}
|
||||||
onClose={() => setViewingReq(null)}
|
onClose={() => setViewingReq(null)}
|
||||||
onEdit={() => handleEdit(viewingReq)}
|
onEdit={canEditRequirement(viewingReq.id, devTasks) ? () => handleEdit(viewingReq) : undefined}
|
||||||
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
||||||
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
|
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
|
||||||
onCloseReq={() => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); }}
|
onCloseReq={canCloseRequirement(viewingReq.id, devTasks) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
|
||||||
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
|
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
||||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, EFFORT_SHORT, EFFORT_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||||
|
|
||||||
interface RequirementDetailProps {
|
interface RequirementDetailProps {
|
||||||
requirement: Requirement;
|
requirement: Requirement;
|
||||||
@@ -10,9 +10,10 @@ interface RequirementDetailProps {
|
|||||||
types: DictItem[];
|
types: DictItem[];
|
||||||
platforms: DictItem[];
|
platforms: DictItem[];
|
||||||
sourceTargets: SourceTarget[];
|
sourceTargets: SourceTarget[];
|
||||||
|
requirements: Requirement[];
|
||||||
resolveVersionName: (id?: string) => string;
|
resolveVersionName: (id?: string) => string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit: () => void;
|
onEdit?: () => void;
|
||||||
onAdopt?: () => void;
|
onAdopt?: () => void;
|
||||||
onReject?: () => void;
|
onReject?: () => void;
|
||||||
onCloseReq?: () => void;
|
onCloseReq?: () => void;
|
||||||
@@ -20,11 +21,11 @@ interface RequirementDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
P0: 'bg-red-500/10 text-red-600',
|
P0: 'bg-red-50 text-red-600 border-red-200',
|
||||||
P1: 'bg-orange-500/10 text-orange-600',
|
P1: 'bg-orange-50 text-orange-600 border-orange-200',
|
||||||
P2: 'bg-blue-500/10 text-blue-600',
|
P2: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||||
P3: 'bg-zinc-100 text-zinc-600',
|
P3: 'bg-zinc-50 text-zinc-600 border-zinc-200',
|
||||||
P4: 'bg-zinc-100 text-zinc-500',
|
P4: 'bg-zinc-50 text-zinc-500 border-zinc-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RequirementDetail({
|
export function RequirementDetail({
|
||||||
@@ -33,6 +34,7 @@ export function RequirementDetail({
|
|||||||
types,
|
types,
|
||||||
platforms,
|
platforms,
|
||||||
sourceTargets,
|
sourceTargets,
|
||||||
|
requirements,
|
||||||
resolveVersionName,
|
resolveVersionName,
|
||||||
onClose,
|
onClose,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -41,120 +43,136 @@ export function RequirementDetail({
|
|||||||
onCloseReq,
|
onCloseReq,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: RequirementDetailProps) {
|
}: RequirementDetailProps) {
|
||||||
const canEdit = ['pending_review', 'adopted', 'planned'].includes(req.status);
|
const canEdit = Boolean(onEdit) && ['pending_review', 'adopted', 'planned'].includes(req.status);
|
||||||
const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-';
|
const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-';
|
||||||
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
|
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
|
||||||
const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-';
|
const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-';
|
||||||
|
const sourceName = `${SOURCE_TYPE_LABEL[req.sourceType]}${req.sourceTarget ? ` / ${req.sourceTarget}` : ''}`;
|
||||||
|
const sourceTargetName = sourceTargets.find((item) => item.name === req.sourceTarget)?.name ?? req.sourceTarget ?? '-';
|
||||||
|
const versionName = resolveVersionName(req.versionId);
|
||||||
|
const parentRequirement = req.parentId ? requirements.find((item) => item.id === req.parentId) : undefined;
|
||||||
|
const parentRequirementLabel = parentRequirement ? `${parentRequirement.code} · ${parentRequirement.title}` : req.parentId || '-';
|
||||||
|
const contextItems = [projectName, versionName, typeName].filter((item) => item && item !== '-');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
|
||||||
<div className="w-[480px] h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col shadow-xl" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
{/* Header */}
|
className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg-card)] shadow-2xl"
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
|
onClick={(e) => e.stopPropagation()}
|
||||||
<div className="flex items-center gap-2.5">
|
>
|
||||||
<span className="text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-5">
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}>
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
{REQ_STATUS_LABEL[req.status]}
|
<span className="shrink-0 text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
|
||||||
</span>
|
<StatusBadge className={REQ_STATUS_COLOR[req.status]}>{REQ_STATUS_LABEL[req.status]}</StatusBadge>
|
||||||
|
<StatusBadge className={PRIORITY_COLORS[req.priority] ?? PRIORITY_COLORS.P3}>{req.priority}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
|
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-5 py-3">
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-[var(--line)]">
|
{canEdit && <ActionButton onClick={onEdit}>编辑</ActionButton>}
|
||||||
{canEdit && (
|
{onAdopt && req.status === 'pending_review' && <ActionButton tone="success" onClick={onAdopt}>采纳</ActionButton>}
|
||||||
<button onClick={onEdit} className="h-7 px-3 rounded-md text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">
|
{onReject && req.status === 'pending_review' && <ActionButton tone="danger" onClick={onReject}>拒绝</ActionButton>}
|
||||||
编辑
|
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && <ActionButton tone="warning" onClick={onCloseReq}>关闭</ActionButton>}
|
||||||
</button>
|
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && <ActionButton tone="danger" onClick={onDelete}>删除</ActionButton>}
|
||||||
)}
|
|
||||||
{onAdopt && req.status === 'pending_review' && (
|
|
||||||
<button onClick={onAdopt} className="h-7 px-3 rounded-md text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 transition-colors">
|
|
||||||
采纳
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onReject && req.status === 'pending_review' && (
|
|
||||||
<button onClick={onReject} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-600 border border-red-200 hover:bg-red-50 transition-colors">
|
|
||||||
拒绝
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && (
|
|
||||||
<button onClick={onCloseReq} className="h-7 px-3 rounded-md text-[12px] font-medium text-orange-600 border border-orange-200 hover:bg-orange-50 transition-colors">
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && (
|
|
||||||
<button onClick={onDelete} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-500 border border-red-200 hover:bg-red-50 transition-colors">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
<section className="border-b border-[var(--line)] px-5 py-5">
|
||||||
{/* 需求概述 */}
|
<h2 className="text-[16px] font-semibold leading-snug text-[var(--ink)]">{req.title}</h2>
|
||||||
<div>
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-[var(--ink-muted)]">
|
||||||
<h2 className="text-[15px] font-semibold text-[var(--ink)] leading-snug">{req.title}</h2>
|
{contextItems.map((item) => <ContextPill key={item}>{item}</ContextPill>)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 需求描述 */}
|
<DetailSection title="需求描述">
|
||||||
{req.description && (
|
{req.description ? (
|
||||||
<div>
|
<p className="whitespace-pre-wrap text-[13px] leading-6 text-[var(--ink-soft)]">{req.description}</p>
|
||||||
<Label>需求描述</Label>
|
) : (
|
||||||
<p className="text-[13px] text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">{req.description}</p>
|
<p className="text-[13px] text-[var(--ink-muted)]">暂无描述</p>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
{/* 基本信息 */}
|
<DetailSection title="基础信息">
|
||||||
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
|
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
|
||||||
<FieldRow label="需求来源" value={`${SOURCE_TYPE_LABEL[req.sourceType]} · ${req.sourceTarget || '-'}`} />
|
<InfoItem label="需求来源" value={sourceName} />
|
||||||
<FieldRow label="所属项目" value={projectName} />
|
<InfoItem label="所属项目" value={projectName} />
|
||||||
<FieldRow label="需求类型" value={typeName} />
|
<InfoItem label="需求类型" value={typeName} />
|
||||||
<FieldRow label="支持端" value={platformNames} />
|
<InfoItem label="支持端" value={platformNames} />
|
||||||
<FieldRow label="优先级">
|
<InfoItem label="所属版本" value={versionName} />
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? ''}`}>
|
<InfoItem label="父需求" value={parentRequirementLabel} />
|
||||||
{req.priority}
|
|
||||||
</span>
|
|
||||||
</FieldRow>
|
|
||||||
{req.effort && (
|
|
||||||
<FieldRow label="工作量">
|
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${EFFORT_COLOR[req.effort]}`}>
|
|
||||||
{EFFORT_SHORT[req.effort]}
|
|
||||||
</span>
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
<FieldRow label="所属版本" value={resolveVersionName(req.versionId)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
{/* 人员 & 日期 */}
|
<DetailSection title="记录信息">
|
||||||
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
|
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
|
||||||
<FieldRow label="产品负责人" value={req.productOwner || '-'} />
|
<InfoItem label="产品负责人" value={req.productOwner || '-'} />
|
||||||
<FieldRow label="录入人员" value={req.creator} />
|
<InfoItem label="录入人员" value={req.creator} />
|
||||||
<FieldRow label="录入日期" value={req.createdAt.slice(0, 10)} />
|
<InfoItem label="录入日期" value={req.createdAt.slice(0, 10)} />
|
||||||
|
<InfoItem label="来源对象" value={sourceTargetName} />
|
||||||
</div>
|
</div>
|
||||||
|
</DetailSection>
|
||||||
{/* 关联父需求 */}
|
|
||||||
{req.parentId && (
|
|
||||||
<div className="rounded-lg border border-[var(--line)]">
|
|
||||||
<FieldRow label="父需求" value={req.parentId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Label({ children }: { children: React.ReactNode }) {
|
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return <div className="text-[11px] font-medium text-[var(--ink-muted)] mb-1">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-4 py-2.5">
|
<section className="border-b border-[var(--line)] px-5 py-4 last:border-b-0">
|
||||||
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
|
<h3 className="mb-3 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
|
||||||
{children ?? <span className="text-[13px] text-[var(--ink)]">{value}</span>}
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-[var(--ink-muted)]">{label}</div>
|
||||||
|
<div className="mt-1 truncate text-[13px] text-[var(--ink)]" title={value}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ className, children }: { className: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextPill({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex max-w-full items-center rounded-md bg-[var(--bg-subtle)] px-2 py-1 text-[11px] text-[var(--ink-soft)]">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
tone = 'neutral',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
tone?: 'neutral' | 'success' | 'warning' | 'danger';
|
||||||
|
}) {
|
||||||
|
const toneClass = {
|
||||||
|
neutral: 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-card)]',
|
||||||
|
success: 'border-emerald-200 text-emerald-700 hover:bg-emerald-50',
|
||||||
|
warning: 'border-orange-200 text-orange-700 hover:bg-orange-50',
|
||||||
|
danger: 'border-red-200 text-red-600 hover:bg-red-50',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`h-7 rounded-md border px-3 text-[12px] font-medium transition-colors ${toneClass}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import type { Requirement, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
import { EFFORT_LABEL, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
import { SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
||||||
|
|
||||||
interface RequirementModalProps {
|
interface RequirementModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,7 +22,6 @@ interface RequirementModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
||||||
const EFFORTS: Effort[] = ['S', 'M', 'L', 'XL'];
|
|
||||||
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
|
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<Priority, string> = {
|
const PRIORITY_COLORS: Record<Priority, string> = {
|
||||||
@@ -57,7 +56,6 @@ export function RequirementModal({
|
|||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||||
const [typeId, setTypeId] = useState('');
|
const [typeId, setTypeId] = useState('');
|
||||||
const [priority, setPriority] = useState<Priority>('P2');
|
const [priority, setPriority] = useState<Priority>('P2');
|
||||||
const [effort, setEffort] = useState<Effort>('M');
|
|
||||||
const [productOwner, setProductOwner] = useState('');
|
const [productOwner, setProductOwner] = useState('');
|
||||||
const [parentId, setParentId] = useState('');
|
const [parentId, setParentId] = useState('');
|
||||||
const [versionId, setVersionId] = useState('');
|
const [versionId, setVersionId] = useState('');
|
||||||
@@ -86,7 +84,6 @@ export function RequirementModal({
|
|||||||
setSelectedPlatforms(initial.platforms || []);
|
setSelectedPlatforms(initial.platforms || []);
|
||||||
setTypeId(initial.typeId || '');
|
setTypeId(initial.typeId || '');
|
||||||
setPriority(initial.priority || 'P2');
|
setPriority(initial.priority || 'P2');
|
||||||
setEffort(initial.effort || 'M');
|
|
||||||
setProductOwner(initial.productOwner || '');
|
setProductOwner(initial.productOwner || '');
|
||||||
setParentId(initial.parentId || '');
|
setParentId(initial.parentId || '');
|
||||||
} else {
|
} else {
|
||||||
@@ -100,7 +97,6 @@ export function RequirementModal({
|
|||||||
setSelectedPlatforms([]);
|
setSelectedPlatforms([]);
|
||||||
setTypeId('');
|
setTypeId('');
|
||||||
setPriority('P2');
|
setPriority('P2');
|
||||||
setEffort('M');
|
|
||||||
setProductOwner('');
|
setProductOwner('');
|
||||||
setParentId('');
|
setParentId('');
|
||||||
}
|
}
|
||||||
@@ -129,7 +125,7 @@ export function RequirementModal({
|
|||||||
platforms: selectedPlatforms,
|
platforms: selectedPlatforms,
|
||||||
typeId: typeId || undefined,
|
typeId: typeId || undefined,
|
||||||
priority,
|
priority,
|
||||||
effort,
|
effort: initial?.effort ?? 'M',
|
||||||
status: initial?.status ?? 'pending_review',
|
status: initial?.status ?? 'pending_review',
|
||||||
creator: initial?.creator || currentUserName || '系统',
|
creator: initial?.creator || currentUserName || '系统',
|
||||||
parentId: parentId || undefined,
|
parentId: parentId || undefined,
|
||||||
|
|||||||
39
apps/web/lib/linkage-engine.test.ts
Normal file
39
apps/web/lib/linkage-engine.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import type { DevTask } from './dev-task';
|
||||||
|
import { canEditRequirement } from './linkage-engine';
|
||||||
|
import * as linkageEngine from './linkage-engine';
|
||||||
|
|
||||||
|
function task(patch: Partial<DevTask> = {}): DevTask {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
taskNo: 'DEV-001',
|
||||||
|
requirementId: 'req-1',
|
||||||
|
title: 'Implement requirement',
|
||||||
|
categoryId: 'cat-frontend',
|
||||||
|
assigneeId: 'Alice',
|
||||||
|
priority: 'P2',
|
||||||
|
expectedStartAt: '2026-06-25T01:00:00.000Z',
|
||||||
|
expectedEndAt: '2026-06-25T02:00:00.000Z',
|
||||||
|
status: 'todo',
|
||||||
|
isBlocked: false,
|
||||||
|
createdBy: 'PM',
|
||||||
|
createdAt: '2026-06-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-25T00:00:00.000Z',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('completed requirement development status cannot be edited', () => {
|
||||||
|
assert.equal(canEditRequirement('req-1', [task({ status: 'submitted' })]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active or completed requirement development status cannot be closed', () => {
|
||||||
|
const canCloseRequirement = (linkageEngine as any).canCloseRequirement;
|
||||||
|
|
||||||
|
assert.equal(typeof canCloseRequirement, 'function');
|
||||||
|
assert.equal(canCloseRequirement('req-1', [task({ status: 'in_progress' })]), false);
|
||||||
|
assert.equal(canCloseRequirement('req-1', [task({ status: 'testing' })]), false);
|
||||||
|
assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), false);
|
||||||
|
});
|
||||||
@@ -34,7 +34,16 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt
|
|||||||
*/
|
*/
|
||||||
export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean {
|
export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean {
|
||||||
const status = deriveReqDevStatus(reqId, devTasks);
|
const status = deriveReqDevStatus(reqId, devTasks);
|
||||||
return status !== 'developing';
|
return !isRequirementExecutionLocked(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCloseRequirement(reqId: string, devTasks: DevTask[]): boolean {
|
||||||
|
const status = deriveReqDevStatus(reqId, devTasks);
|
||||||
|
return !isRequirementExecutionLocked(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequirementExecutionLocked(status: ReqDevStatus): boolean {
|
||||||
|
return status === 'developing' || status === 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
86
apps/web/lib/requirement-scope.test.ts
Normal file
86
apps/web/lib/requirement-scope.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import type { Requirement } from './requirement';
|
||||||
|
import {
|
||||||
|
buildRequirementScopeTree,
|
||||||
|
filterRequirementsByScope,
|
||||||
|
type RequirementScopeSelection,
|
||||||
|
} from './requirement-scope';
|
||||||
|
|
||||||
|
const baseRequirement = {
|
||||||
|
code: 'REQ-001',
|
||||||
|
title: 'Requirement',
|
||||||
|
description: '',
|
||||||
|
sourceType: 'internal',
|
||||||
|
sourceTarget: '',
|
||||||
|
platforms: [] as string[],
|
||||||
|
typeId: '',
|
||||||
|
priority: 'P2',
|
||||||
|
effort: 'M',
|
||||||
|
creator: 'tester',
|
||||||
|
createdAt: '2026-06-29T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
function req(id: string, productId: string, projectId: string, status: Requirement['status'] = 'pending_review'): Requirement {
|
||||||
|
return {
|
||||||
|
...baseRequirement,
|
||||||
|
id,
|
||||||
|
code: id.toUpperCase(),
|
||||||
|
productId,
|
||||||
|
projectId,
|
||||||
|
status,
|
||||||
|
} as Requirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = [
|
||||||
|
{
|
||||||
|
id: 'product-1',
|
||||||
|
name: 'CRM',
|
||||||
|
projects: [
|
||||||
|
{ id: 'project-1', name: 'Mobile', description: '', createdAt: '2026-06-29T00:00:00.000Z' },
|
||||||
|
{ id: 'project-2', name: 'Admin', description: '', createdAt: '2026-06-29T00:00:00.000Z' },
|
||||||
|
],
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product-2',
|
||||||
|
name: 'BI',
|
||||||
|
projects: [
|
||||||
|
{ id: 'project-3', name: 'Dashboard', description: '', createdAt: '2026-06-29T00:00:00.000Z' },
|
||||||
|
],
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('filters requirements by all product and project scopes', () => {
|
||||||
|
const requirements = [
|
||||||
|
req('req-1', 'product-1', 'project-1'),
|
||||||
|
req('req-2', 'product-1', 'project-2'),
|
||||||
|
req('req-3', 'product-2', 'project-3'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const allScope: RequirementScopeSelection = { type: 'all' };
|
||||||
|
const productScope: RequirementScopeSelection = { type: 'product', productId: 'product-1' };
|
||||||
|
const projectScope: RequirementScopeSelection = { type: 'project', projectId: 'project-2' };
|
||||||
|
|
||||||
|
assert.deepEqual(filterRequirementsByScope(requirements, allScope).map((item) => item.id), ['req-1', 'req-2', 'req-3']);
|
||||||
|
assert.deepEqual(filterRequirementsByScope(requirements, productScope).map((item) => item.id), ['req-1', 'req-2']);
|
||||||
|
assert.deepEqual(filterRequirementsByScope(requirements, projectScope).map((item) => item.id), ['req-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds product and project tree counts from requirements', () => {
|
||||||
|
const tree = buildRequirementScopeTree(overview, [
|
||||||
|
req('req-1', 'product-1', 'project-1', 'pending_review'),
|
||||||
|
req('req-2', 'product-1', 'project-1', 'developing'),
|
||||||
|
req('req-3', 'product-1', 'project-2', 'closed'),
|
||||||
|
req('req-4', 'product-2', 'project-3', 'adopted'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(tree[0].count.total, 3);
|
||||||
|
assert.equal(tree[0].count.byStatus.pending_review, 1);
|
||||||
|
assert.equal(tree[0].projects[0].count.total, 2);
|
||||||
|
assert.equal(tree[0].projects[0].count.byStatus.developing, 1);
|
||||||
|
assert.equal(tree[0].projects[1].count.total, 1);
|
||||||
|
assert.equal(tree[1].count.total, 1);
|
||||||
|
});
|
||||||
70
apps/web/lib/requirement-scope.ts
Normal file
70
apps/web/lib/requirement-scope.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Requirement, RequirementStatus } from './requirement';
|
||||||
|
|
||||||
|
export type RequirementScopeSelection =
|
||||||
|
| { type: 'all' }
|
||||||
|
| { type: 'product'; productId: string }
|
||||||
|
| { type: 'project'; projectId: string };
|
||||||
|
|
||||||
|
export interface RequirementScopeCount {
|
||||||
|
total: number;
|
||||||
|
byStatus: Partial<Record<RequirementStatus, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequirementProjectScopeNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: RequirementScopeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequirementProductScopeNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: RequirementScopeCount;
|
||||||
|
projects: RequirementProjectScopeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRequirementsByScope(
|
||||||
|
requirements: Requirement[],
|
||||||
|
scope: RequirementScopeSelection,
|
||||||
|
): Requirement[] {
|
||||||
|
if (scope.type === 'product') {
|
||||||
|
return requirements.filter((requirement) => requirement.productId === scope.productId);
|
||||||
|
}
|
||||||
|
if (scope.type === 'project') {
|
||||||
|
return requirements.filter((requirement) => requirement.projectId === scope.projectId);
|
||||||
|
}
|
||||||
|
return requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequirementScopeTree(
|
||||||
|
overview: { id: string; name: string; projects: { id: string; name: string }[] }[],
|
||||||
|
requirements: Requirement[],
|
||||||
|
): RequirementProductScopeNode[] {
|
||||||
|
return overview.map((product) => {
|
||||||
|
const productRequirements = requirements.filter((requirement) => requirement.productId === product.id);
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
count: countRequirements(productRequirements),
|
||||||
|
projects: product.projects.map((project) => {
|
||||||
|
const projectRequirements = requirements.filter((requirement) => requirement.projectId === project.id);
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
count: countRequirements(projectRequirements),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRequirements(requirements: Requirement[]): RequirementScopeCount {
|
||||||
|
const byStatus: Partial<Record<RequirementStatus, number>> = {};
|
||||||
|
for (const requirement of requirements) {
|
||||||
|
byStatus[requirement.status] = (byStatus[requirement.status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: requirements.length,
|
||||||
|
byStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user