"use client"; import React, { useMemo, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ClipboardList, Filter, Search, ChevronRight, ExternalLink, Tag, Calendar, Loader2, Plus, } from "lucide-react"; import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge"; import { EmptyState } from "@/components/ui/EmptyState"; import { artifacts, projects } from "@/lib/api"; import { cn, fmtDate, shortId, truncate } from "@/lib/utils"; import type { Artifact } from "@/lib/types"; const DEMO_REQS: Artifact[] = [ { id: "req-001-aaaaaaaa", tenant_id: "t1", project_id: "p1", type: "requirement", external_id: "REQ-441", title: "Multi-tenant role-based access control", content: "As a platform administrator I want to define organization roles (owner, admin, member, viewer) so that I can enforce least-privilege access on every ASF module.", status: "in_progress", version: 3, source: "jira", created_by: "u1", created_at: "2025-10-02T09:12:00Z", updated_at: "2025-11-15T14:22:00Z", }, { id: "req-002-bbbbbbbb", tenant_id: "t1", project_id: "p1", type: "requirement", external_id: "REQ-442", title: "Audit log immutability & retention", content: "Audit events must be append-only and retained for 365 days with tamper-evident hashing. Export to JSON/CSV must be signed.", status: "backlog", version: 1, source: "manual", created_by: "u2", created_at: "2025-10-18T11:40:00Z", updated_at: "2025-10-18T11:40:00Z", }, { id: "req-003-cccccccc", tenant_id: "t1", project_id: "p2", type: "epic", external_id: "EPIC-12", title: "Traceability graph v1", content: "End-to-end artifact visualization: requirement → tasks → PR → builds → tests → deployments, with impact analysis sidebar.", status: "in_progress", version: 5, source: "linear", created_by: "u1", created_at: "2025-09-10T08:00:00Z", updated_at: "2025-11-20T09:02:00Z", }, { id: "req-004-dddddddd", tenant_id: "t1", project_id: "p2", type: "requirement", external_id: "REQ-501", title: "AI Gateway provider abstraction", content: "Business logic must not depend on a specific LLM provider. Introduce ASF AI Gateway with adapter pattern, policy routing, usage metering.", status: "done", version: 2, source: "manual", created_at: "2025-08-22T16:15:00Z", updated_at: "2025-11-05T12:30:00Z", }, { id: "req-005-eeeeeeee", tenant_id: "t1", project_id: "p1", type: "requirement", external_id: "REQ-518", title: "SSO with SAML 2.0 & OIDC", content: "Support Enterprise SSO: Okta, Azure AD, Google Workspace. SCIM provisioning for automated user/group lifecycle.", status: "blocked", version: 1, source: "jira", created_at: "2025-11-01T10:10:00Z", updated_at: "2025-11-22T08:00:00Z", }, ]; const STATUS_TONES: Record = { backlog: "neutral", todo: "neutral", in_progress: "blue", review: "violet", done: "green", blocked: "red", cancelled: "amber", }; export default function RequirementsPage() { const queryClient = useQueryClient(); const [q, setQ] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all"); const [projectFilter, setProjectFilter] = useState("all"); const [selectedId, setSelectedId] = useState(null); /* ---------- New requirement modal ---------- */ const [showNew, setShowNew] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newExternalId, setNewExternalId] = useState(""); const [newStatus, setNewStatus] = useState("backlog"); const [newProjectId, setNewProjectId] = useState(""); const [newContent, setNewContent] = useState(""); const [newSource, setNewSource] = useState("manual"); const [newMetaText, setNewMetaText] = useState("{}\n"); const [newMetaError, setNewMetaError] = useState(null); const parsedMeta = useMemo | null>(() => { const s = newMetaText.trim(); if (!s) return {}; try { const v = JSON.parse(s); if (v === null || typeof v !== "object" || Array.isArray(v)) { setNewMetaError("metadata must be a JSON object (not null/array/primitive)."); return null; } setNewMetaError(null); return v as Record; } catch (e) { setNewMetaError("Invalid JSON in metadata"); return null; } }, [newMetaText]); const createReqM = useMutation({ mutationFn: async () => { if (!newTitle.trim()) throw new Error("title required"); if (!newProjectId) throw new Error("project required"); if (parsedMeta === null) throw new Error("metadata JSON invalid"); return artifacts.create({ project_id: newProjectId, type: "requirement", title: newTitle.trim(), external_id: newExternalId.trim() || undefined, status: newStatus || undefined, content: newContent.trim() || undefined, source: newSource.trim() || undefined, metadata: parsedMeta, }); }, onSuccess: (data) => { setShowNew(false); setNewTitle(""); setNewExternalId(""); setNewStatus("backlog"); setNewProjectId(""); setNewContent(""); setNewSource("manual"); setNewMetaText("{}\n"); queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined); queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined); setSelectedId(String(data.id)); }, }); const projectsQ = useQuery({ queryKey: ["projects", "list"], queryFn: () => projects.list({ limit: 200 }), retry: false, refetchOnWindowFocus: false, }); const listQ = useQuery({ queryKey: [ "artifacts", "list", { status: statusFilter, type: typeFilter, project: projectFilter, q: q.trim() }, ], queryFn: () => artifacts.list({ type: typeFilter !== "all" ? (typeFilter as any) : undefined, status: statusFilter !== "all" ? statusFilter : undefined, project_id: projectFilter !== "all" ? projectFilter : undefined, search: q.trim() || undefined, limit: 200, }), retry: false, refetchOnWindowFocus: false, }); const apiResolved = listQ.isFetched && listQ.data !== undefined; const rows: Artifact[] = apiResolved ? listQ.data! : DEMO_REQS; const isMock = !apiResolved; const projectNameById = useMemo(() => { const m = new Map(); for (const p of projectsQ.data ?? []) { if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id))); } return m; }, [projectsQ.data]); const filtered = useMemo(() => { return rows.filter((r) => { if (projectFilter !== "all" && String(r.project_id) !== projectFilter) return false; if (typeFilter !== "all" && r.type !== typeFilter) return false; if (statusFilter !== "all" && (r.status ?? "backlog") !== statusFilter) return false; const ql = q.trim().toLowerCase(); if (ql) { const hay = `${r.title} ${r.external_id ?? ""} ${r.content ?? ""}`.toLowerCase(); if (!hay.includes(ql)) return false; } return true; }); }, [rows, projectFilter, typeFilter, statusFilter, q]); const selected = filtered.find((r) => r.id === selectedId) ?? filtered[0] ?? null; const statuses = Array.from(new Set(rows.map((r) => r.status ?? "backlog"))); const types = Array.from(new Set(rows.map((r) => r.type))); const projectOptions = useMemo(() => { const setRows = new Map(); for (const r of rows) setRows.set(String(r.project_id), projectNameById.get(String(r.project_id)) ?? shortId(String(r.project_id))); for (const p of projectsQ.data ?? []) { if (p?.id) setRows.set(String(p.id), p.name ?? shortId(String(p.id))); } return Array.from(setRows.entries()).map(([id, name]) => ({ id, name })); }, [rows, projectsQ.data, projectNameById]); return (

Requirements

Every lifecycle item in ASF is an Artifact. Requirements, epics, stories, tasks — searchable, linkable, traceable.

{isMock && Demo data}
setQ(e.target.value)} />
{filtered.length} / {rows.length}
Artifact list Click a row → {filtered.length === 0 ? (
) : ( {filtered.map((r) => ( setSelectedId(r.id)} className={cn( "cursor-pointer hover:bg-brand-50/40 transition-colors", selected?.id === r.id && "bg-brand-50/70", )} > ))}
ID Type Title Status Updated
{r.external_id ?? shortId(r.id)} {r.title} {r.status ?? "backlog"} {fmtDate(r.updated_at)}
)}
Detail Traceability {selected ? (
{selected.external_id ?? shortId(selected.id)} {selected.status ?? "backlog"} {selected.version !== undefined && ( v{selected.version} )}

{selected.title}

{selected.content && (
{selected.content}
)}
Project
{projectNameById.get(String(selected.project_id)) ?? ( {shortId(selected.project_id)} )}
Source
{selected.source ?? "—"}
Created
{fmtDate(selected.created_at)}
Updated
{fmtDate(selected.updated_at)}

Description preview: {truncate(selected.content ?? "", 160).length > 0 ? truncate(selected.content ?? "", 160) : "No description."}

) : ( )}
{/* New requirement modal */} {showNew && (
!createReqM.isPending && setShowNew(false)}>
e.stopPropagation()} >

New Requirement

{ e.preventDefault(); createReqM.mutate(); }} >
setNewTitle(e.target.value)} autoFocus required />
setNewExternalId(e.target.value)} />
setNewSource(e.target.value)} />