learning_ai_notes/web/src/app/(app)/dashboard/page.tsx
2026-03-10 18:21:32 -07:00

216 lines
8.3 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
import { listApprovalQueue } from "@/lib/review-client";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
export default function DashboardPage() {
const [notes, setNotes] = useState<NoteSummary[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [pendingReviewCount, setPendingReviewCount] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [nextNotes, nextWorkspaces, nextApprovalQueue] = await Promise.all([
listNoteSummaries(),
listWorkspaceSummaries(),
listApprovalQueue(),
]);
setNotes(nextNotes);
setWorkspaces(nextWorkspaces);
setPendingReviewCount(nextApprovalQueue.length);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load dashboard data");
}
})();
}, []);
const savedViews = [
{
id: "workspace-all",
name: "All workspaces",
scope: "workspace",
description: "Current workspace inventory derived from backend-backed workspace data.",
query: "visibility:any sort:updated",
resultCount: workspaces.length,
},
{
id: "draft-notes",
name: "Draft notes",
scope: "search",
description: "Draft notes currently tracked across the active knowledge base.",
query: "status:draft",
resultCount: notes.filter((note) => note.status === "draft").length,
},
{
id: "pending-review",
name: "Pending review",
scope: "review",
description: "Current agent-mediated items awaiting review.",
query: "state:draft|proposed",
resultCount: pendingReviewCount,
},
];
const operatorWorkflows = [
{
id: "workflow-approvals",
name: "Approval triage",
owner: "Operator",
queueCount: pendingReviewCount,
sla: "< 4h",
status: pendingReviewCount > 3 ? "at_risk" : "healthy",
},
{
id: "workflow-workspaces",
name: "Workspace coverage",
owner: "Knowledge Ops",
queueCount: workspaces.length,
sla: "< 1d",
status: workspaces.length > 5 ? "at_risk" : "healthy",
},
] as const;
const recentNotes = notes.slice(0, 3);
function getSavedViewHref(view: (typeof savedViews)[number]) {
if (view.id === "workspace-all") {
return "/workspaces";
}
if (view.id === "draft-notes") {
return `/search?q=${encodeURIComponent("draft")}`;
}
return "/reviews";
}
function getWorkflowHref(workflow: (typeof operatorWorkflows)[number]) {
if (workflow.id === "workflow-workspaces") {
return "/workspaces";
}
return "/reviews";
}
const summaryCards = [
{
id: "summary-workspaces",
label: "Active workspaces",
value: workspaces.length,
href: "/workspaces",
},
{
id: "summary-notes",
label: "Tracked notes",
value: notes.length,
href: "/search",
},
{
id: "summary-reviews",
label: "Pending review surfaces",
value: pendingReviewCount,
href: "/reviews",
},
] as const;
return (
<AppShell
title="Dashboard"
description="Operational entry point for recent notes, active workspaces, and agent-relevant follow-ups."
actions={<div className="badge">Operational shell</div>}
>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--ml-space-4)" }}>
{summaryCards.map((card) => (
<Link key={card.id} href={card.href} className="surface-card" style={{ padding: "var(--ml-space-5)" }}>
<div style={{ color: "var(--ml-text-secondary)" }}>{card.label}</div>
<div style={{ fontSize: "var(--ml-fs-3xl)", fontWeight: 700, marginTop: "var(--ml-space-2)" }}>{card.value}</div>
</Link>
))}
</section>
<section style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.1fr) minmax(320px, 0.9fr)", gap: "var(--ml-space-4)" }}>
<section className="surface-card" style={{ padding: "var(--ml-space-6)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Saved views</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{savedViews.map((view) => (
<Link
key={view.id}
href={getSavedViewHref(view)}
className="surface-muted"
style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<strong>{view.name}</strong>
<span className="badge">{view.scope}</span>
</div>
<div style={{ color: "var(--ml-text-secondary)" }}>{view.description}</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.resultCount} results</span>
</div>
</Link>
))}
</div>
</section>
<section className="surface-card" style={{ padding: "var(--ml-space-6)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Operator workflows</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{operatorWorkflows.map((workflow) => (
<Link
key={workflow.id}
href={getWorkflowHref(workflow)}
className="surface-muted"
style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<strong>{workflow.name}</strong>
<span className="badge">{workflow.status}</span>
</div>
<div style={{ color: "var(--ml-text-secondary)" }}>Owner: {workflow.owner}</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<span style={{ color: "var(--ml-text-secondary)" }}>Queue: {workflow.queueCount}</span>
<span style={{ color: "var(--ml-text-secondary)" }}>SLA: {workflow.sla}</span>
</div>
</Link>
))}
</div>
</section>
</section>
<section className="surface-card" style={{ padding: "var(--ml-space-6)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Recent note activity</div>
{error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null}
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{recentNotes.map((note) => (
<div key={note.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<Link href={`/notes/${note.id}`}>
<strong>{note.title}</strong>
</Link>
<span style={{ color: "var(--ml-text-secondary)" }}>{note.updatedBy}</span>
</div>
<Link href={`/notes/${note.id}`} style={{ color: "var(--ml-text-secondary)" }}>
{note.excerpt}
</Link>
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}>
{note.tags.map((tag) => (
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`} className="badge">
#{tag}
</Link>
))}
</div>
</div>
))}
</div>
</section>
</AppShell>
);
}