This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+605
View File
@@ -0,0 +1,605 @@
"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>
);
}