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