feat(phase2): planned badge, Cmd+K palette, per-project dashboard cards

- Project.status (planned/wired/active) + seed updates
- ProjectBadge component
- CommandPalette (⌘K) wired in PanelShell with project + nav + sign-out
- Dashboard '/' shows per-project cards + KPI strip
- Added doner312 to seeded projects
This commit is contained in:
Semih
2026-05-13 10:54:07 +00:00
parent 7b83ef8f11
commit e455ebbfce
14 changed files with 782 additions and 57 deletions

View File

@@ -1,42 +1,97 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { AppSidebar } from "@/components/app-sidebar";
import { ChartAreaInteractive } from "@/components/chart-area-interactive";
import { SectionCards } from "@/components/section-cards";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ProjectBadge } from "@/lib/project-badge";
import { prisma } from "@/lib/db";
import { saseDb } from "@/lib/db-sase";
export const dynamic = "force-dynamic";
async function fetchProjectMetric(key: string): Promise<string | null> {
try {
if (key === "sase") {
const users = await saseDb.user.count();
return `${users} users`;
}
return null;
} catch {
return null;
}
}
async function fetchPanelStats() {
const [audit24h, audit7d] = await Promise.all([
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
}),
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
}),
]);
return { audit24h, audit7d };
}
export default async function Home() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const [projects, stats] = await Promise.all([
prisma.project.findMany({ orderBy: [{ status: "desc" }, { name: "asc" }] }),
fetchPanelStats(),
]);
const projectsWithMetric = await Promise.all(
projects.map(async (p) => ({ ...p, metric: await fetchProjectMetric(p.key) })),
);
const active = projectsWithMetric.filter((p) => p.status === "active").length;
const planned = projectsWithMetric.filter((p) => p.status === "planned").length;
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar
variant="inset"
user={{ name: session.user.name, email: session.user.email }}
/>
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
<div className="px-4 lg:px-6">
<ChartAreaInteractive />
</div>
</div>
</div>
<PanelShell title="Overview">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<KpiCard label="Active spokes" value={active.toString()} hint={`of ${projects.length} total`} />
<KpiCard label="Planned" value={planned.toString()} hint="awaiting wiring" />
<KpiCard label="Audit (24h)" value={stats.audit24h.toString()} hint="all actions logged" />
<KpiCard label="Audit (7d)" value={stats.audit7d.toString()} hint="rolling window" />
</div>
<div>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Projects</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithMetric.map((p) => (
<Link key={p.id} href={`/projects/${p.key}`} className="block">
<Card className="h-full transition-colors hover:border-foreground/40">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{p.name}</CardTitle>
<ProjectBadge status={p.status} />
</div>
<CardDescription>{p.description ?? "—"}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm tabular-nums">
{p.metric ?? <span className="text-muted-foreground"></span>}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</SidebarInset>
</SidebarProvider>
</div>
<p className="mt-4 text-xs text-muted-foreground">
Tip: <kbd className="rounded border px-1.5 py-0.5">K</kbd> for command palette
</p>
</PanelShell>
);
}
function KpiCard({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
</Card>
);
}

View File

@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { PanelShell } from "@/components/panel-shell";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ProjectBadge } from "@/lib/project-badge";
import { SaseHealth } from "./_sase";
export const dynamic = "force-dynamic";
@@ -16,9 +16,7 @@ export default async function ProjectDetail({ params }: { params: Promise<{ key:
<PanelShell title={project.name}>
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">{project.name}</h2>
<Badge variant={project.active ? "default" : "outline"}>
{project.active ? "wired" : "not wired"}
</Badge>
<ProjectBadge status={project.status} />
</div>
<p className="text-sm text-muted-foreground">{project.description}</p>

View File

@@ -1,13 +1,15 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
import { PanelShell } from "@/components/panel-shell";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ProjectBadge } from "@/lib/project-badge";
export const dynamic = "force-dynamic";
export default async function ProjectsPage() {
const projects = await prisma.project.findMany({ orderBy: { name: "asc" } });
const projects = await prisma.project.findMany({
orderBy: [{ status: "desc" }, { name: "asc" }],
});
return (
<PanelShell title="Projects">
@@ -18,18 +20,13 @@ export default async function ProjectsPage() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{p.name}</CardTitle>
<Badge variant={p.active ? "default" : "outline"}>
{p.active ? "wired" : "not wired"}
</Badge>
<ProjectBadge status={p.status} />
</div>
<CardDescription>{p.description ?? "—"}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
{projects.length === 0 && (
<p className="text-sm text-muted-foreground">No projects yet run prisma seed.</p>
)}
</div>
</PanelShell>
);