first
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user