first
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
X,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { askAsf, jobs } from "@/lib/api";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
|
||||
import { cn, fmtDate, truncate } from "@/lib/utils";
|
||||
import type { Job } from "@/lib/types";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "system" | "assistant";
|
||||
kind?: "question" | "info" | "success" | "error";
|
||||
content: string;
|
||||
ts: Date;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export function AskAsfCommandBar() {
|
||||
const open = useAppStore((s) => s.asfOpen);
|
||||
const closeAsf = useAppStore((s) => s.closeAsf);
|
||||
const openAsf = useAppStore((s) => s.openAsf);
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: "welcome",
|
||||
role: "system",
|
||||
kind: "info",
|
||||
content:
|
||||
"Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.",
|
||||
ts: new Date(),
|
||||
},
|
||||
]);
|
||||
const [pollingJobId, setPollingJobId] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// open via global event + ⌘K
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
if (meta && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
if (open) closeAsf(); else openAsf();
|
||||
}
|
||||
if (e.key === "Escape" && open) closeAsf();
|
||||
}
|
||||
function onOpenEv() { openAsf(); }
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("asf:open-command", onOpenEv);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("asf:open-command", onOpenEv);
|
||||
};
|
||||
}, [open, openAsf, closeAsf]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [open]);
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (question: string) =>
|
||||
askAsf.query({ question }),
|
||||
onMutate: (question) => {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{ id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() },
|
||||
]);
|
||||
},
|
||||
onSuccess: (resp) => {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
id: "subm" + Date.now(),
|
||||
role: "system",
|
||||
kind: "info",
|
||||
content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`,
|
||||
ts: new Date(),
|
||||
jobId: resp.job_id,
|
||||
},
|
||||
]);
|
||||
setPollingJobId(resp.job_id);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{ id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() },
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
// long-poll the accepted job until terminal
|
||||
useQuery({
|
||||
queryKey: ["ask-job", pollingJobId],
|
||||
enabled: !!pollingJobId,
|
||||
refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500),
|
||||
queryFn: async (): Promise<Job | undefined> => {
|
||||
if (!pollingJobId) return undefined;
|
||||
const job = await jobs.get(pollingJobId);
|
||||
if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) {
|
||||
setPollingJobId(null);
|
||||
setMessages((m) => {
|
||||
// avoid adding duplicate result messages
|
||||
if (m.some(x => x.id === `res-${job.id}`)) return m;
|
||||
if (job.status === "completed" && job.result) {
|
||||
const answer =
|
||||
(job.result as Record<string, unknown>).answer as string ||
|
||||
JSON.stringify(job.result, null, 2);
|
||||
return [
|
||||
...m,
|
||||
{
|
||||
id: `res-${job.id}`,
|
||||
role: "assistant",
|
||||
kind: "success",
|
||||
content: answer,
|
||||
ts: new Date(),
|
||||
jobId: job.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
return [
|
||||
...m,
|
||||
{
|
||||
id: `fail-${job.id}`,
|
||||
role: "system",
|
||||
kind: "error",
|
||||
content: job.error || "Unknown AI job failure.",
|
||||
ts: new Date(),
|
||||
jobId: job.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return job;
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!q.trim()) return;
|
||||
submitMutation.mutate(q.trim());
|
||||
setQ("");
|
||||
}
|
||||
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-slate-950/40 backdrop-blur-[2px] animate-fade-in"
|
||||
onClick={closeAsf}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-slate-200 bg-gradient-to-r from-brand-50/60 to-transparent">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-accent grid place-items-center text-white shadow-sm">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-slate-800 leading-tight">Ask ASF</div>
|
||||
<div className="text-[11px] text-slate-500 leading-tight">
|
||||
Context-aware queries backed by the ASF AI Gateway
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="violet">
|
||||
<MessageCircle className="w-3 h-3 mr-1" />
|
||||
mock provider
|
||||
</Badge>
|
||||
<button className="asf-btn-ghost !p-1.5" onClick={closeAsf} aria-label="Close">
|
||||
<X className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* messages */}
|
||||
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} m={m} />
|
||||
))}
|
||||
{submitMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Submitting query to ASF AI job queue…
|
||||
</div>
|
||||
)}
|
||||
{pollingJobId && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Polling job <code className="asf-monospace text-slate-700 bg-slate-50 rounded px-1.5 py-0.5 border border-slate-200">{pollingJobId.slice(0,12)}</code> until terminal state…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* toggle for expanded mini-chat */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="hidden w-full items-center justify-center gap-1 py-2 text-xs text-slate-500 border-t border-slate-100 hover:bg-slate-50"
|
||||
>
|
||||
{expanded ? <>Collapse <ChevronDown className="w-3 h-3" /></> : <>Expand <ChevronUp className="w-3 h-3" /></>}
|
||||
</button>
|
||||
|
||||
{/* input */}
|
||||
<form onSubmit={onSubmit} className="px-5 py-3.5 border-t border-slate-200 bg-slate-50/50 flex gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(e);
|
||||
}}
|
||||
placeholder={
|
||||
expanded
|
||||
? 'e.g. "Show requirements without tests", "Which services implement REQ-441?"'
|
||||
: "Ask ASF… (⌘↩ to send)"
|
||||
}
|
||||
className="asf-input resize-none"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="self-end shrink-0"
|
||||
disabled={!q.trim() || submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden md:inline">Send</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="px-5 pb-4 flex flex-wrap gap-2 bg-slate-50/50">
|
||||
{[
|
||||
"Show requirements without tests.",
|
||||
"Why did yesterday's build fail?",
|
||||
"Which services implement REQ-441?",
|
||||
"Generate tests for TASK-921.",
|
||||
].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setQ(s)}
|
||||
className="asf-chip hover:bg-brand-50 hover:text-brand-700 hover:border-brand-100 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ m }: { m: ChatMessage }) {
|
||||
const Icon =
|
||||
m.kind === "success" ? CheckCircle2 :
|
||||
m.kind === "error" ? AlertCircle :
|
||||
m.kind === "question" ? MessageCircle : MessageCircle;
|
||||
const tone =
|
||||
m.role === "assistant" ? "bg-brand-50 border-brand-100" :
|
||||
m.role === "user" ? "bg-slate-50 border-slate-200" :
|
||||
m.kind === "error" ? "bg-red-50 border-red-100" :
|
||||
"bg-white border-slate-200";
|
||||
const toneIcon =
|
||||
m.kind === "success" ? "text-emerald-600" :
|
||||
m.kind === "error" ? "text-red-600" :
|
||||
m.role === "user" ? "text-slate-600" :
|
||||
"text-brand-600";
|
||||
return (
|
||||
<div className={cn("flex gap-3 animate-fade-in")}>
|
||||
<div className={cn("w-7 h-7 rounded-lg grid place-items-center shrink-0 border", tone)}>
|
||||
<Icon className={cn("w-4 h-4", toneIcon)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<span className="font-medium capitalize text-slate-600">{m.role}</span>
|
||||
<span>· {fmtDate(m.ts)}</span>
|
||||
{m.jobId && <JobStatusBadge status={"queued"} />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"mt-1 rounded-lg border text-sm px-3 py-2 whitespace-pre-wrap break-words",
|
||||
tone,
|
||||
m.role === "assistant" ? "asf-monospace !font-sans leading-relaxed" : "",
|
||||
)}>
|
||||
{truncate(m.content, 1200)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ClipboardList,
|
||||
Search,
|
||||
Code2,
|
||||
GitBranch,
|
||||
Bot,
|
||||
GitFork,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Plug,
|
||||
BarChart3,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export const NAV: NavItem[] = [
|
||||
{ href: "/", label: "Overview", icon: LayoutDashboard },
|
||||
{ href: "/requirements", label: "Requirements", icon: ClipboardList },
|
||||
{ href: "/context", label: "Context Explorer", icon: Search },
|
||||
{ href: "/code", label: "Code", icon: Code2 },
|
||||
{ href: "/traceability", label: "Traceability", icon: GitBranch },
|
||||
{ href: "/ask-asf", label: "Ask ASF", icon: Sparkles, badge: "AI" },
|
||||
{ href: "/jobs", label: "Jobs", icon: Activity },
|
||||
{ href: "/ai-agents", label: "AI Agents", icon: Bot, badge: "α" },
|
||||
{ href: "/pipelines", label: "Pipelines", icon: GitFork },
|
||||
{ href: "/deployments", label: "Deployments", icon: Rocket },
|
||||
{ href: "/quality", label: "Quality", icon: ShieldCheck },
|
||||
{ href: "/observability", label: "Observability", icon: Activity },
|
||||
{ href: "/integrations", label: "Integrations", icon: Plug },
|
||||
{ href: "/usage", label: "Usage", icon: BarChart3 },
|
||||
{ href: "/settings", label: "Settings", icon: Settings2 },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const path = usePathname();
|
||||
return (
|
||||
<aside className="hidden lg:flex w-64 shrink-0 border-r border-slate-200/80 bg-white/60 backdrop-blur-sm flex-col">
|
||||
<div className="h-16 flex items-center gap-3 px-5 border-b border-slate-200/80">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-accent shadow-soft grid place-items-center text-white font-bold">
|
||||
A
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-800 leading-tight">
|
||||
{process.env.NEXT_PUBLIC_APP_NAME || "ASF"}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 leading-tight">
|
||||
Software Factory
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-0.5">
|
||||
{NAV.map(({ href, label, icon: Icon, badge }) => {
|
||||
const active = href === "/" ? path === "/" : path.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn("asf-nav-item", active && "asf-nav-item-active")}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px] shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{badge && (
|
||||
<span className="asf-chip border-accent/20 bg-accent/10 text-accent-muted !px-1.5">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-slate-200/80">
|
||||
<button
|
||||
onClick={() => {
|
||||
// keyboard shortcut is handled in layout; this button also triggers.
|
||||
const ev = new CustomEvent("asf:open-command");
|
||||
window.dispatchEvent(ev);
|
||||
}}
|
||||
className="w-full asf-btn-secondary justify-between !px-3"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="text-slate-700">Ask ASF…</span>
|
||||
</span>
|
||||
<kbd className="asf-chip !text-[10px]">⌘ K</kbd>
|
||||
</button>
|
||||
<p className="mt-3 text-[11px] text-slate-400 leading-relaxed">
|
||||
Tip: every lifecycle element in ASF is an <span className="font-mono text-slate-600 bg-slate-100 rounded px-1">Artifact</span>.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Bell,
|
||||
Search,
|
||||
ChevronDown,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import { orgs, projects } from "@/lib/api";
|
||||
import { Dropdown, type Option } from "@/components/ui/Dropdown";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Topbar({ className }: { className?: string }) {
|
||||
const setOrg = useAppStore((s) => s.setOrg);
|
||||
const setProject = useAppStore((s) => s.setProject);
|
||||
const currentOrg = useAppStore((s) => s.org_id);
|
||||
const currentProject = useAppStore((s) => s.project_id);
|
||||
const openAsf = useAppStore((s) => s.openAsf);
|
||||
|
||||
const { data: orgList = [] } = useQuery({
|
||||
queryKey: ["orgs"],
|
||||
queryFn: () => orgs.list({ limit: 50 }),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: projectList = [] } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => projects.list({ limit: 100 }),
|
||||
initialData: [],
|
||||
enabled: !!currentOrg,
|
||||
});
|
||||
|
||||
const orgOptions: Option<string>[] = orgList.map((o) => ({
|
||||
value: o.id,
|
||||
label: o.name,
|
||||
description: o.slug,
|
||||
}));
|
||||
if (orgOptions.length === 0) {
|
||||
// Dev-mode synthetic org (matches NEXT_PUBLIC_DEV_TENANT_ID)
|
||||
orgOptions.push({
|
||||
value: currentOrg || "00000000-0000-0000-0000-000000000001",
|
||||
label: "Azurian Labs",
|
||||
description: "azurian-labs (bootstrap demo)",
|
||||
});
|
||||
}
|
||||
|
||||
const projectOptions: Option<string>[] = projectList.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.key} · ${p.name}`,
|
||||
description: p.description || undefined,
|
||||
}));
|
||||
// Add a synthetic "All projects" option
|
||||
projectOptions.unshift({
|
||||
value: "__all__",
|
||||
label: "All projects",
|
||||
description: "Tenant-wide view",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProject && projectList.length > 0) {
|
||||
setProject(projectList[0].id);
|
||||
}
|
||||
}, [currentProject, projectList, setProject]);
|
||||
|
||||
return (
|
||||
<header className={cn(
|
||||
"h-16 shrink-0 border-b border-slate-200/80 bg-white/80 backdrop-blur",
|
||||
"flex items-center gap-4 px-4 md:px-6",
|
||||
className,
|
||||
)}>
|
||||
{/* Mobile brand */}
|
||||
<div className="lg:hidden flex items-center gap-2 font-semibold text-slate-800">
|
||||
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-brand-500 to-accent text-white grid place-items-center text-sm font-bold">
|
||||
A
|
||||
</div>
|
||||
ASF
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-xl hidden md:block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAsf}
|
||||
className="w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-slate-500
|
||||
bg-slate-50 hover:bg-slate-100 border border-slate-200 transition-colors text-left"
|
||||
>
|
||||
<Search className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
|
||||
<span className="flex-1">Search artifacts, projects, requirements… or press <span className="font-medium text-slate-600">⌘ K</span></span>
|
||||
<kbd className="asf-chip !text-[10px]">Search</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 md:gap-3">
|
||||
<Dropdown
|
||||
label="Organization"
|
||||
size="sm"
|
||||
icon={<Building2 className="w-4 h-4" />}
|
||||
options={orgOptions}
|
||||
value={currentOrg}
|
||||
onChange={(v) => {
|
||||
setOrg(v);
|
||||
setProject(undefined);
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
label="Project"
|
||||
size="sm"
|
||||
icon={<FolderKanban className="w-4 h-4" />}
|
||||
options={projectOptions}
|
||||
value={currentProject === undefined ? "__all__" : currentProject}
|
||||
onChange={(v) => setProject(v === "__all__" ? undefined : v)}
|
||||
/>
|
||||
<Button variant="ghost" size="md" aria-label="Notifications">
|
||||
<Bell className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
<div className="h-9 w-9 rounded-full bg-slate-100 border border-slate-200 grid place-items-center text-slate-600">
|
||||
<UserCircle className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BadgeTone =
|
||||
| "neutral"
|
||||
| "brand"
|
||||
| "green"
|
||||
| "amber"
|
||||
| "red"
|
||||
| "violet"
|
||||
| "blue";
|
||||
|
||||
const TONES: Record<BadgeTone, string> = {
|
||||
neutral: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
brand: "bg-brand-50 text-brand-700 border-brand-100",
|
||||
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
|
||||
amber: "bg-amber-50 text-amber-700 border-amber-100",
|
||||
red: "bg-red-50 text-red-700 border-red-100",
|
||||
violet: "bg-violet-50 text-violet-700 border-violet-100",
|
||||
blue: "bg-sky-50 text-sky-700 border-sky-100",
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
|
||||
TONES[tone],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobStatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, BadgeTone> = {
|
||||
queued: "neutral",
|
||||
running: "blue",
|
||||
completed: "green",
|
||||
failed: "red",
|
||||
cancelled: "amber",
|
||||
};
|
||||
return <Badge tone={map[status] ?? "neutral"}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export function ArtifactTypeBadge({ type }: { type: string }) {
|
||||
const map: Record<string, BadgeTone> = {
|
||||
requirement: "brand",
|
||||
epic: "violet",
|
||||
story: "brand",
|
||||
task: "blue",
|
||||
architecture_decision: "violet",
|
||||
document: "neutral",
|
||||
source_file: "neutral",
|
||||
commit: "green",
|
||||
pull_request: "blue",
|
||||
build: "amber",
|
||||
test: "green",
|
||||
security_finding: "red",
|
||||
deployment: "green",
|
||||
release: "green",
|
||||
runtime_event: "amber",
|
||||
incident: "red",
|
||||
ai_analysis: "violet",
|
||||
};
|
||||
return <Badge tone={map[type] ?? "neutral"}>{type}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
as?: "button" | "a";
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const variantClass: Record<Variant, string> = {
|
||||
primary: "asf-btn-primary",
|
||||
secondary: "asf-btn-secondary",
|
||||
ghost: "asf-btn-ghost",
|
||||
danger: "asf-btn bg-red-50 text-red-700 hover:bg-red-100 border-red-100",
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: "text-xs px-2.5 py-1.5",
|
||||
md: "text-sm px-3.5 py-2",
|
||||
lg: "text-base px-5 py-2.5",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className,
|
||||
as = "button",
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const cls = cn(variantClass[variant], sizeClass[size], className);
|
||||
if (as === "a" && href) {
|
||||
return (
|
||||
<a href={href} className={cls} {...(rest as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button className={cls} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card-header", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card-title", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card-body", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("px-5 py-3 border-t border-slate-100 text-xs text-slate-500 flex items-center justify-between", className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Option<T = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Dropdown<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
className,
|
||||
label,
|
||||
size = "md",
|
||||
tone = "secondary",
|
||||
icon,
|
||||
empty = "No options",
|
||||
}: {
|
||||
options: Option<T>[];
|
||||
value?: T;
|
||||
onChange: (v: T) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
size?: "sm" | "md";
|
||||
tone?: "secondary" | "ghost";
|
||||
icon?: React.ReactNode;
|
||||
empty?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function h(e: MouseEvent) {
|
||||
if (!ref.current) return;
|
||||
if (!ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
const current = options.find((o) => o.value === value);
|
||||
const btnClass = cn(
|
||||
"inline-flex items-center gap-2 border rounded-lg transition-colors",
|
||||
size === "sm" ? "text-xs px-2.5 py-1.5" : "text-sm px-3 py-2",
|
||||
tone === "secondary"
|
||||
? "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
|
||||
: "bg-transparent border-transparent text-slate-700 hover:bg-slate-100",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-block text-left", className)} ref={ref}>
|
||||
{label && <div className="text-[11px] uppercase tracking-wide text-slate-500 mb-1">{label}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={btnClass}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{icon && <span className="text-slate-500">{icon}</span>}
|
||||
<span className={cn("truncate", !current && "text-slate-400")}>
|
||||
{current?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown className={cn("w-4 h-4 text-slate-400 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-1 right-0 min-w-[240px] rounded-xl bg-white border border-slate-200 shadow-soft overflow-hidden animate-fade-in">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-slate-500 text-center">{empty}</div>
|
||||
) : (
|
||||
<ul className="max-h-80 overflow-auto py-1">
|
||||
{options.map((o) => (
|
||||
<li key={String(o.value)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 text-sm transition-colors",
|
||||
value === o.value
|
||||
? "bg-brand-50 text-brand-700 font-medium"
|
||||
: "text-slate-700 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="truncate">{o.label}</div>
|
||||
{o.description && (
|
||||
<div className="text-xs text-slate-500 mt-0.5 truncate">{o.description}</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Inbox, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export function EmptyState({
|
||||
title = "Nothing here yet",
|
||||
description = "Create some data in ASF or wait a moment for it to load.",
|
||||
icon: Icon = Inbox,
|
||||
action,
|
||||
actionLabel,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
action?: () => void;
|
||||
actionLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-14 px-6 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/60 animate-fade-in",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-white border border-slate-200 grid place-items-center text-slate-500 shadow-sm mb-4">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-slate-800 mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-500 max-w-md mb-5">{description}</p>
|
||||
{action && actionLabel && (
|
||||
<Button variant="primary" size="sm" onClick={action}>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardBody } from "./Card";
|
||||
import { Badge } from "./Badge";
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
tone = "neutral",
|
||||
icon,
|
||||
subtitle,
|
||||
isPercentage,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
delta?: number; // percentage change, e.g. +5.2
|
||||
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "violet" | "blue";
|
||||
icon?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
isPercentage?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const showValue = isPercentage
|
||||
? `${Number(value).toFixed(1)}%`
|
||||
: typeof value === "number"
|
||||
? value.toLocaleString()
|
||||
: value;
|
||||
|
||||
return (
|
||||
<Card className={cn("relative overflow-hidden", className)}>
|
||||
<CardBody className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold text-slate-900 tabular-nums">
|
||||
{showValue}
|
||||
</div>
|
||||
{subtitle !== undefined && (
|
||||
<div className="mt-1 text-xs text-slate-500">{subtitle}</div>
|
||||
)}
|
||||
{delta !== undefined && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs">
|
||||
{delta >= 0 ? (
|
||||
<>
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-emerald-700 font-medium">+{delta.toFixed(1)}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-600" />
|
||||
<span className="text-red-700 font-medium">{delta.toFixed(1)}%</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-slate-400 ml-1">vs last period</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg grid place-items-center shrink-0 border",
|
||||
tone === "brand" && "bg-brand-50 border-brand-100 text-brand-600",
|
||||
tone === "green" && "bg-emerald-50 border-emerald-100 text-emerald-600",
|
||||
tone === "red" && "bg-red-50 border-red-100 text-red-600",
|
||||
tone === "amber" && "bg-amber-50 border-amber-100 text-amber-600",
|
||||
tone === "violet" && "bg-violet-50 border-violet-100 text-violet-600",
|
||||
tone === "blue" && "bg-sky-50 border-sky-100 text-sky-600",
|
||||
tone === "neutral" && "bg-slate-50 border-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricPill({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
isPercentage,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "blue";
|
||||
isPercentage?: boolean;
|
||||
}) {
|
||||
const shown = isPercentage ? `${Number(value).toFixed(1)}%` : value;
|
||||
const toneMap: Record<typeof tone, string> = {
|
||||
neutral: "bg-slate-100 text-slate-700",
|
||||
brand: "bg-brand-50 text-brand-700",
|
||||
blue: "bg-sky-50 text-sky-700",
|
||||
green: "bg-emerald-50 text-emerald-700",
|
||||
red: "bg-red-50 text-red-700",
|
||||
amber: "bg-amber-50 text-amber-700",
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<Badge tone={tone === "neutral" ? "neutral" : tone === "brand" ? "brand" : tone === "green" ? "green" : tone === "red" ? "red" : "amber"}>
|
||||
<span className="tabular-nums font-semibold">{shown}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Donut-style progress ring.
|
||||
*
|
||||
* Used in the Overview dashboard for things like implementation coverage,
|
||||
* test coverage, project health score, traceability score.
|
||||
*/
|
||||
export function ProgressRing({
|
||||
value,
|
||||
size = 80,
|
||||
stroke = 8,
|
||||
tone = "brand",
|
||||
label,
|
||||
sublabel,
|
||||
className,
|
||||
}: {
|
||||
value: number; // 0..100
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
tone?: "brand" | "green" | "amber" | "red" | "violet";
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const clamped = Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const offset = c - (clamped / 100) * c;
|
||||
|
||||
const toneMap: Record<string, string> = {
|
||||
brand: "text-brand-600",
|
||||
green: "text-emerald-600",
|
||||
amber: "text-amber-600",
|
||||
red: "text-red-600",
|
||||
violet: "text-violet-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-4", className)}>
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className={cn("-rotate-90", toneMap[tone])}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.12}
|
||||
fill="transparent"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-500 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<div className="text-sm font-semibold tabular-nums text-slate-900">
|
||||
{clamped.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(label || sublabel) && (
|
||||
<div className="min-w-0">
|
||||
{label && <div className="text-sm font-medium text-slate-800">{label}</div>}
|
||||
{sublabel && <div className="text-xs text-slate-500 mt-0.5">{sublabel}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user