Files
2026-09-08 07:21:34 -04:00

606 lines
24 KiB
TypeScript
Raw Permalink 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 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<string, "green" | "amber" | "red" | "brand" | "neutral" | "violet" | "blue"> = {
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<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
/* ---------- New requirement modal ---------- */
const [showNew, setShowNew] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newExternalId, setNewExternalId] = useState("");
const [newStatus, setNewStatus] = useState<string>("backlog");
const [newProjectId, setNewProjectId] = useState<string>("");
const [newContent, setNewContent] = useState("");
const [newSource, setNewSource] = useState("manual");
const [newMetaText, setNewMetaText] = useState<string>("{}\n");
const [newMetaError, setNewMetaError] = useState<string | null>(null);
const parsedMeta = useMemo<Record<string, unknown> | 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<string, unknown>;
} 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<string, string>();
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<string, string>();
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 (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<ClipboardList className="w-6 h-6 text-brand-600" />
Requirements
</h1>
<p className="mt-1 text-sm text-slate-500">
Every lifecycle item in ASF is an Artifact. Requirements, epics, stories, tasks — searchable, linkable, traceable.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo data</Badge>}
<Button variant="primary" size="sm" onClick={() => {
setShowNew(true);
if ((projectsQ.data ?? []).length > 0 && !newProjectId) {
setNewProjectId(String(projectsQ.data![0].id));
}
}}>
<Plus className="w-4 h-4" /> New Requirement
</Button>
</div>
</header>
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search by id, title, content…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Filter className="w-3.5 h-3.5" />
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
>
<option value="all">All projects</option>
{projectOptions.map(({ id, name }) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All statuses</option>
{statuses.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">All types</option>
{types.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<Badge tone="brand">
<span className="tabular-nums font-semibold">{filtered.length}</span> / {rows.length}
</Badge>
</div>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Artifact list</CardTitle>
<span className="text-xs text-slate-500">Click a row →</span>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
{filtered.length === 0 ? (
<div className="p-6">
<EmptyState
title="No artifacts match"
description="Try adjusting filters or the search term."
/>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-left px-4 py-2">Title</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((r) => (
<tr
key={r.id}
onClick={() => setSelectedId(r.id)}
className={cn(
"cursor-pointer hover:bg-brand-50/40 transition-colors",
selected?.id === r.id && "bg-brand-50/70",
)}
>
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">
{r.external_id ?? shortId(r.id)}
</td>
<td className="px-4 py-2.5">
<ArtifactTypeBadge type={r.type} />
</td>
<td className="px-4 py-2.5 font-medium text-slate-800 max-w-md truncate">
{r.title}
</td>
<td className="px-4 py-2.5">
<Badge tone={STATUS_TONES[r.status ?? "backlog"] ?? "neutral"}>
{r.status ?? "backlog"}
</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">
{fmtDate(r.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</CardBody>
</Card>
<Card className="xl:col-span-2 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Detail</CardTitle>
<Link href="/traceability" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
Traceability <ChevronRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selected ? (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="asf-monospace text-xs text-slate-500">
{selected.external_id ?? shortId(selected.id)}
</span>
<ArtifactTypeBadge type={selected.type} />
<Badge tone={STATUS_TONES[selected.status ?? "backlog"] ?? "neutral"}>
{selected.status ?? "backlog"}
</Badge>
{selected.version !== undefined && (
<Badge tone="neutral">v{selected.version}</Badge>
)}
</div>
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
{selected.title}
</h2>
</div>
{selected.content && (
<div className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
{selected.content}
</div>
)}
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<dt className="text-xs text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{projectNameById.get(String(selected.project_id)) ?? (
<span className="asf-monospace">{shortId(selected.project_id)}</span>
)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Source</dt>
<dd className="font-medium text-slate-800 inline-flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> {selected.source ?? "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> Created
</dt>
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> Updated
</dt>
<dd className="text-slate-700">{fmtDate(selected.updated_at)}</dd>
</div>
</dl>
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
<Button variant="secondary" size="sm">View relationships</Button>
<Button variant="secondary" size="sm">History</Button>
<Button variant="primary" size="sm">Ask ASF about this…</Button>
</div>
<p className="text-xs text-slate-400 pt-1">
Description preview: {truncate(selected.content ?? "", 160).length > 0 ? truncate(selected.content ?? "", 160) : "No description."}
</p>
</div>
) : (
<EmptyState
title="Select an artifact"
description="Pick any row on the left to inspect its metadata and content."
/>
)}
</CardBody>
</Card>
</div>
{/* New requirement modal */}
{showNew && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createReqM.isPending && setShowNew(false)}>
<div
className="w-full max-w-2xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Tag className="w-4 h-4 text-brand-600" /> New Requirement
</h3>
<button
onClick={() => !createReqM.isPending && setShowNew(false)}
disabled={createReqM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>
×
</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => {
e.preventDefault();
createReqM.mutate();
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="text-xs text-slate-500">Title *</label>
<input
className="asf-input mt-1"
placeholder="As a user I want …"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
autoFocus
required
/>
</div>
<div>
<label className="text-xs text-slate-500">External ID</label>
<input
className="asf-input mt-1"
placeholder="REQ-104"
value={newExternalId}
onChange={(e) => setNewExternalId(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-slate-500">Project *</label>
<select
className="asf-input mt-1"
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
required
>
<option value="">Select project…</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>
{p.name ?? shortId(String(p.id))}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Status</label>
<select
className="asf-input mt-1"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
>
{["backlog","todo","in_progress","review","done","blocked","cancelled"].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Source</label>
<input
className="asf-input mt-1"
placeholder="manual"
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-xs text-slate-500">Content / Description</label>
<textarea
rows={4}
className="asf-input mt-1"
placeholder="Acceptance criteria, non-goals, links…"
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-slate-500 flex items-center justify-between mb-1">
<span>Metadata (JSON)</span>
{newMetaError && <span className="text-red-600 font-medium">{newMetaError}</span>}
</label>
<textarea
rows={4}
className={cn("asf-input mt-0", newMetaError && "border-red-300 focus:ring-red-200")}
value={newMetaText}
onChange={(e) => setNewMetaText(e.target.value)}
spellCheck={false}
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
/>
<p className="text-[11px] text-slate-500 mt-1">
Example: <code className="bg-slate-100 rounded px-1">{"{\"priority\": \"P0\", \"points\": 8}"} </code>
</p>
</div>
{createReqM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createReqM.error instanceof Error ? createReqM.error.message : "Failed to create requirement."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setShowNew(false)}
disabled={createReqM.isPending}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
disabled={createReqM.isPending || !newTitle.trim() || !newProjectId || parsedMeta === null}
>
{createReqM.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Creating…
</>
) : (
<>
<Plus className="w-4 h-4" /> Create requirement
</>
)}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}