diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index da51533..56209c7 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -28,7 +28,7 @@ import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; import { hasPermission } from '@/lib/permissions'; import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours'; import { formatDateTime, formatLocalDate } from '@/lib/format'; -import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; +import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector'; import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link'; import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview'; import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; @@ -1022,7 +1022,7 @@ export default function VersionDetailPage() { ) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? ( (() => { const pt = activeTab as 'research' | 'product' | 'ui'; - const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId); + const versionLinkedReqs = getVersionLinkedRequirementCandidates(displayRequirements, displayRequirementIds); return ( { diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 553922d..9ce9478 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -9,12 +9,15 @@ import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { FilterSelect } from '@/components/FilterSelect'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; +import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector'; import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours'; import type { Priority } from '@/lib/derive'; +import type { Requirement } from '@/lib/requirement'; interface Props { versionId: string; requirementIds: string[]; + versionRequirements?: Requirement[]; versionDeadline?: string; onClose: () => void; onCreated?: () => void; @@ -32,16 +35,17 @@ function defaultExpectedEnd(): string { return isoToLocal(d.toISOString()); } -export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) { +export function DevTaskCreateModal({ versionId, requirementIds, versionRequirements, versionDeadline, onClose, onCreated }: Props) { const { createTask, tasks } = useDevTaskStore(); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); const user = useAuthStore((s) => s.user); + const requirementSource = versionRequirements ?? requirements; const versionReqs = useMemo( - () => requirements.filter((r) => requirementIds.includes(r.id)), - [requirements, requirementIds], + () => getVersionLinkedRequirementCandidates(requirementSource, requirementIds), + [requirementSource, requirementIds], ); const versionTasks = useMemo( () => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)), @@ -65,6 +69,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, if (categories[0] && !categoryId) setCategoryId(categories[0].id); }, [categories, categoryId]); + useEffect(() => { + if (versionReqs.length === 0) { + if (requirementId) setRequirementId(''); + return; + } + if (versionReqs.some((r) => r.id === requirementId)) return; + const nextRequirement = versionReqs[0]; + setRequirementId(nextRequirement.id); + if (!priorityManuallySet) setPriority(nextRequirement.priority); + }, [versionReqs, requirementId, priorityManuallySet]); + const expectedStartISO = localToISO(expectedStartLocal); const expectedEndISO = localToISO(expectedEndLocal); diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index 8cfb5b9..570c4eb 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -254,7 +254,15 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl {total > 20 && } - {showCreate && !readOnly && setShowCreate(false)} />} + {showCreate && !readOnly && ( + setShowCreate(false)} + /> + )} {selectedTaskId && setSelectedTaskId(null)} />} ); diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx index 654e77c..fc25650 100644 --- a/apps/web/components/test-case/TestCaseCreateModal.tsx +++ b/apps/web/components/test-case/TestCaseCreateModal.tsx @@ -10,13 +10,16 @@ import { useAuthStore } from '@/stores/useAuthStore'; import { FilterSelect } from '@/components/FilterSelect'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import type { Priority } from '@/lib/derive'; +import type { Requirement } from '@/lib/requirement'; import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category'; +import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector'; import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; import { isoToLocal, localToISO } from '@/lib/work-hours'; interface Props { versionId: string; requirementIds: string[]; + versionRequirements?: Requirement[]; roundNo?: number; onClose: () => void; } @@ -33,16 +36,17 @@ function defaultPlannedEndLocal(): string { return isoToLocal(d.toISOString()); } -export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) { +export function TestCaseCreateModal({ versionId, requirementIds, versionRequirements, roundNo, onClose }: Props) { const { createTestCase } = useTestCaseStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); const { categories } = useTaskCategoryStore(); const user = useAuthStore((s) => s.user); + const requirementSource = versionRequirements ?? requirements; const versionReqs = useMemo( - () => requirements.filter((r) => requirementIds.includes(r.id)), - [requirements, requirementIds], + () => getVersionLinkedRequirementCandidates(requirementSource, requirementIds), + [requirementSource, requirementIds], ); const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]); @@ -69,6 +73,12 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos setEstimateHours(getDefaultTestCaseEstimateHours(next.code)); }, [categoryId, testCategories]); + useEffect(() => { + if (!requirementId) return; + if (versionReqs.some((r) => r.id === requirementId)) return; + setRequirementId(''); + }, [versionReqs, requirementId]); + const handleCategoryChange = (nextCategoryId: string) => { setCategoryId(nextCategoryId); const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId); diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx index 150a6cf..61264f5 100644 --- a/apps/web/components/test-case/TestCaseTab.tsx +++ b/apps/web/components/test-case/TestCaseTab.tsx @@ -332,7 +332,15 @@ export function TestCaseTab({ {total > 20 && } - {showCreate && !readOnly && setShowCreate(false)} />} + {showCreate && !readOnly && ( + setShowCreate(false)} + /> + )} {selectedCaseId && setSelectedCaseId(null)} onCreateBug={(id) => { if (!readOnly) setBugForCaseId(id); }} />} {bugForCaseId && !readOnly && setBugForCaseId(null)} />} diff --git a/apps/web/lib/requirement-selector.test.ts b/apps/web/lib/requirement-selector.test.ts index 52f6b64..6769f2d 100644 --- a/apps/web/lib/requirement-selector.test.ts +++ b/apps/web/lib/requirement-selector.test.ts @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector'; +import { + getProjectAdoptedRequirementCandidates, + getVersionLinkedRequirementCandidates, + mergeSelectedRequirementOptions, +} from './requirement-selector'; import type { Requirement } from './requirement'; const base = { @@ -38,6 +42,17 @@ test('returns only adopted requirements from current project', () => { assert.deepEqual(result.map((r: Requirement) => r.id), ['r1']); }); +test('returns only requirements already linked to current version', () => { + const result = getVersionLinkedRequirementCandidates([ + req('r1', 'project-1', 'adopted'), + req('r2', 'project-1', 'developing'), + req('r3', 'project-1', 'adopted'), + req('r4', 'project-2', 'adopted'), + ], ['r2', 'r1']); + + assert.deepEqual(result.map((r: Requirement) => r.id), ['r2', 'r1']); +}); + test('keeps historical selected requirements as non-candidate options', () => { const options = mergeSelectedRequirementOptions( [req('r1', 'project-1', 'adopted')], diff --git a/apps/web/lib/requirement-selector.ts b/apps/web/lib/requirement-selector.ts index cc90743..16545da 100644 --- a/apps/web/lib/requirement-selector.ts +++ b/apps/web/lib/requirement-selector.ts @@ -26,6 +26,22 @@ export function getProjectAdoptedRequirementCandidates(requirements: Requirement .sort((a, b) => a.code.localeCompare(b.code, 'zh-CN')); } +export function getVersionLinkedRequirementCandidates(requirements: Requirement[], requirementIds: string[]): Requirement[] { + const linkedOrder = new Map(); + requirementIds.forEach((id, index) => { + if (!linkedOrder.has(id)) linkedOrder.set(id, index); + }); + + return requirements + .filter((r) => linkedOrder.has(r.id)) + .sort((a, b) => { + const orderA = linkedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = linkedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return a.code.localeCompare(b.code, 'zh-CN'); + }); +} + export function mergeSelectedRequirementOptions( candidates: Requirement[], allRequirements: Requirement[],