feat(portal+admin): shared shell, customer portal (dashboard nav, subscriptions, settings) + admin portal (order queue w/ status actions, source registry) + admin/subscriptions APIs

PortalShell (sidebar + session bar via /api/auth/me + logout). Customer: /portal nav, /portal/subscriptions (create feed), /portal/settings (dev-login + DPA). Admin: /admin (metrics + order queue with inline status advance), /admin/sources. APIs: /api/admin/metrics, /api/subscriptions. tsc 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 21:29:27 +02:00
co-authored by Claude Opus 4.8
parent ba96566c14
commit f4fa1c0a5e
9 changed files with 548 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PortalShell, StatusPill } from "@/components/portal-shell";
import { LoadingSkeleton, ErrorState, EmptyState } from "@/components/states";
import { formatSEK } from "@/lib/data";
interface Order {
order_id: string;
flow: string;
status: string;
total_ore: number;
deposit_ore: number;
created_at: string;
}
interface Metrics {
total: number;
in_production: number;
review: number;
delivered: number;
deposit_paid_ore: number;
pipeline_ore: number;
}
const NEXT_STATUS: Record<string, string> = {
draft: "quoted",
quoted: "in_production",
in_production: "review",
review: "accepted",
accepted: "delivered",
};
export default function AdminPage() {
const [orders, setOrders] = useState<Order[] | null>(null);
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
setError(false);
try {
const [o, m] = await Promise.all([
fetch("/api/orders", { cache: "no-store" }).then((r) => r.json()),
fetch("/api/admin/metrics", { cache: "no-store" }).then((r) => r.json()),
]);
setOrders(Array.isArray(o) ? o : []);
setMetrics(m);
} catch {
setError(true);
}
}, []);
useEffect(() => { void load(); }, [load]);
async function advance(id: string, status: string) {
const next = NEXT_STATUS[status];
if (!next) return;
await fetch(`/api/orders/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }) });
void load();
}
return (
<PortalShell variant="admin" title="Orderkö & drift">
{error ? (
<ErrorState onRetry={load} />
) : !metrics || !orders ? (
<LoadingSkeleton rows={6} />
) : (
<>
<div className="grid grid-cols-2 gap-px border border-border bg-border md:grid-cols-5">
{[
["Ordrar", String(metrics.total)],
["I produktion", String(metrics.in_production)],
["Granskas", String(metrics.review)],
["Levererade", String(metrics.delivered)],
["Förskott in", formatSEK((metrics.deposit_paid_ore || 0) / 100)],
].map(([l, v]) => (
<div key={l} className="bg-background p-5">
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{l}</p>
<p className="mt-2 text-2xl font-medium tabular-nums tracking-[-0.02em]">{v}</p>
</div>
))}
</div>
<section className="mt-10">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Orderkö</h2>
{orders.length === 0 ? (
<div className="mt-6"><EmptyState title="Tom kö." line="Inga ordrar än." /></div>
) : (
<div className="mt-4 border border-border">
<div className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>ID</span><span>Flöde</span><span>Status</span><span className="text-right">Summa</span><span className="text-right">Åtgärd</span>
</div>
{orders.map((o) => (
<div key={o.order_id} className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] items-center border-b border-border px-4 py-3 last:border-b-0">
<span className="font-mono text-[12px] tabular-nums">{o.order_id.slice(0, 8)}…</span>
<span className="text-sm">{o.flow}</span>
<StatusPill status={o.status} />
<span className="text-right font-mono text-[12px] tabular-nums">{formatSEK(o.total_ore / 100)}</span>
<span className="text-right">
{NEXT_STATUS[o.status] ? (
<button
onClick={() => advance(o.order_id, o.status)}
className="border border-foreground px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors hover:bg-foreground hover:text-background"
>
→ {NEXT_STATUS[o.status]}
</button>
) : (
<span className="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">klar</span>
)}
</span>
</div>
))}
</div>
)}
</section>
</>
)}
</PortalShell>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PortalShell } from "@/components/portal-shell";
import { LoadingSkeleton, ErrorState } from "@/components/states";
interface Source {
source_id: string;
name: string;
market: string;
access: string;
licence: string;
cadence: string;
confidence: number;
}
const ACCESS_COLOR: Record<string, string> = {
api: "var(--prov-3)", bulk: "var(--prov-2)", authorized_web: "var(--prov-1)", licensed: "var(--prov-0)",
};
export default function AdminSourcesPage() {
const [sources, setSources] = useState<Source[] | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
setError(false);
try {
const r = await fetch("/api/sources", { cache: "no-store" });
setSources(await r.json());
} catch { setError(true); }
}, []);
useEffect(() => { void load(); }, [load]);
return (
<PortalShell variant="admin" title="Källregister & färskhet">
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
Laglig sourcing. API &gt; bulk &gt; auktoriserad webb &gt; licens. Verifiera villkor.
</p>
{error ? (
<div className="mt-6"><ErrorState onRetry={load} /></div>
) : !sources ? (
<div className="mt-6"><LoadingSkeleton rows={8} /></div>
) : (
<div className="mt-6 border border-border">
<div className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>Källa</span><span>Marknad</span><span>Åtkomst</span><span>Licens</span><span>Färskhet</span>
</div>
{sources.map((s) => (
<div key={s.source_id} className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">
<span className="font-medium">{s.name}</span>
<span className="font-mono text-xs">{s.market}</span>
<span className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.06em]">
<span className="inline-block h-2 w-2" style={{ backgroundColor: ACCESS_COLOR[s.access] ?? "var(--muted-foreground)" }} />
{s.access}
</span>
<span className="truncate pr-3 text-xs text-muted-foreground">{s.licence}</span>
<span className="font-mono text-xs text-muted-foreground">{s.cadence}</span>
</div>
))}
</div>
)}
</PortalShell>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { listOrders } from "@/lib/orders-store";
// GET /api/admin/metrics — order queue aggregates for the admin dashboard.
export async function GET() {
const orders = await listOrders();
const byStatus: Record<string, number> = {};
let depositPaidOre = 0;
let pipelineOre = 0;
for (const o of orders) {
byStatus[o.status] = (byStatus[o.status] ?? 0) + 1;
if (o.status !== "draft") depositPaidOre += o.deposit_ore ?? 0;
pipelineOre += o.total_ore ?? 0;
}
return NextResponse.json({
total: orders.length,
by_status: byStatus,
in_production: byStatus["in_production"] ?? 0,
review: byStatus["review"] ?? 0,
delivered: byStatus["delivered"] ?? 0,
deposit_paid_ore: depositPaidOre,
pipeline_ore: pipelineOre,
});
}
+16
View File
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { createSubscription, listSubscriptions } from "@/lib/subscriptions-store";
// GET /api/subscriptions — feeds list. POST — convert an order to a recurring feed.
export async function GET() {
return NextResponse.json(await listSubscriptions());
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
if (!body.order_id) {
return NextResponse.json({ error: { code: "bad_request", message: "order_id krävs" } }, { status: 400 });
}
const sub = await createSubscription(body.order_id, body.cadence ?? "weekly", body.webhook_url ?? null);
return NextResponse.json(sub, { status: 201 });
}
+19
View File
@@ -94,6 +94,25 @@ export default function PortalPage() {
</p>
</header>
<nav className="flex flex-wrap gap-2 border-b border-border py-4">
{[
["Översikt", "/portal"],
["Prenumerationer", "/portal/subscriptions"],
["Filer", "/portal/files"],
["Integrationer", "/portal/integrationer"],
["Inställningar", "/portal/settings"],
["Admin", "/admin"],
].map(([l, h]) => (
<Link
key={h}
href={h}
className="border border-border px-3 py-1.5 font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
>
{l}
</Link>
))}
</nav>
<Content
orders={orders}
error={error}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import { PortalShell } from "@/components/portal-shell";
export default function SettingsPage() {
const [email, setEmail] = useState<string | null>(null);
const [loginEmail, setLoginEmail] = useState("");
const [key, setKey] = useState("");
const [msg, setMsg] = useState("");
async function refresh() {
const d = await fetch("/api/auth/me").then((r) => r.json()).catch(() => ({ user: null }));
setEmail(d?.user?.email ?? null);
}
useEffect(() => { void refresh(); }, []);
async function login() {
setMsg("");
const r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: loginEmail, key }) });
if (r.ok) { setMsg("Inloggad."); void refresh(); }
else { const e = await r.json().catch(() => ({})); setMsg(e?.error?.message ?? `Fel (${r.status})`); }
}
async function logout() { await fetch("/api/auth/logout", { method: "POST" }); void refresh(); }
return (
<PortalShell variant="customer" title="Inställningar">
<section className="border border-border p-6">
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Konto</p>
{email ? (
<div className="mt-4 flex items-center justify-between">
<span className="text-sm">Inloggad som <span className="font-mono">{email}</span></span>
<button onClick={logout} className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors hover:bg-foreground hover:text-background">Logga ut</button>
</div>
) : (
<div className="mt-4 flex flex-wrap items-end gap-3">
<input value={loginEmail} onChange={(e) => setLoginEmail(e.target.value)} placeholder="e-post" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
<input value={key} onChange={(e) => setKey(e.target.value)} placeholder="dev-nyckel" type="password" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
<button onClick={login} className="bg-foreground px-5 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]">Logga in</button>
{msg ? <span className="font-mono text-[11px] text-muted-foreground">{msg}</span> : null}
</div>
)}
<p className="mt-4 font-mono text-[10.5px] text-muted-foreground">
Skarp inloggning (Auth.js + OAuth) aktiveras när databasen är kopplad. Dev-login kräver AUTH_SECRET + DEV_LOGIN_KEY.
</p>
</section>
<section className="mt-6 border border-border p-6">
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Datahantering &amp; DPA</p>
<ul className="mt-4 space-y-2.5 text-sm">
{[
["Härkomst per fält", "Källa, färskhet, konfidens — på varje uppgift."],
["GDPR-native", "Laglig grund och DPA per leverans."],
["Din data: du pushar", "Read-only roll du skapar. Tidsbegränsad. Raderad efteråt."],
["Revisionslogg", "Vad. När. Varför."],
].map(([t, s]) => (
<li key={t} className="flex items-start gap-2">
<span className="mt-0.5" style={{ color: "var(--prov-3)" }}>✓</span>
<span><span className="font-medium">{t}.</span> <span className="text-muted-foreground">{s}</span></span>
</li>
))}
</ul>
</section>
</PortalShell>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PortalShell, StatusPill } from "@/components/portal-shell";
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
interface Sub {
sub_id: string;
order_id: string;
cadence: string;
status: string;
webhook_url: string | null;
}
export default function SubscriptionsPage() {
const [subs, setSubs] = useState<Sub[] | null>(null);
const [error, setError] = useState(false);
const [orderId, setOrderId] = useState("");
const [cadence, setCadence] = useState("weekly");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setError(false);
try { setSubs(await (await fetch("/api/subscriptions", { cache: "no-store" })).json()); }
catch { setError(true); }
}, []);
useEffect(() => { void load(); }, [load]);
async function create() {
if (!orderId || busy) return;
setBusy(true);
await fetch("/api/subscriptions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_id: orderId, cadence }) }).catch(() => {});
setOrderId("");
setBusy(false);
void load();
}
return (
<PortalShell variant="customer" title="Prenumerationer & flöden">
<div className="border border-border p-5">
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Gör om en order till ett flöde</p>
<div className="mt-4 flex flex-wrap items-end gap-3">
<input value={orderId} onChange={(e) => setOrderId(e.target.value)} placeholder="order_id" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
<select value={cadence} onChange={(e) => setCadence(e.target.value)} className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground">
<option value="weekly">Veckovis</option>
<option value="daily">Dagligt</option>
</select>
<button onClick={create} disabled={busy || !orderId} className="bg-foreground px-5 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">
{busy ? "Skapar…" : "Skapa flöde"}
</button>
</div>
</div>
<div className="mt-8">
{error ? <ErrorState onRetry={load} /> : !subs ? <LoadingSkeleton rows={4} /> : subs.length === 0 ? (
<EmptyState title="Inga flöden än." line="Gör om en order till ett återkommande flöde." />
) : (
<div className="border border-border">
<div className="grid grid-cols-[1.6fr_1fr_1fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>Order</span><span>Kadens</span><span>Status</span><span>Webhook</span>
</div>
{subs.map((s) => (
<div key={s.sub_id} className="grid grid-cols-[1.6fr_1fr_1fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">
<span className="font-mono text-[12px] tabular-nums">{s.order_id.slice(0, 8)}…</span>
<span className="font-mono text-xs">{s.cadence}</span>
<StatusPill status={s.status} />
<span className="truncate font-mono text-[11px] text-muted-foreground">{s.webhook_url ?? "—"}</span>
</div>
))}
</div>
)}
</div>
</PortalShell>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
export type NavItem = { label: string; href: string };
const CUSTOMER_NAV: NavItem[] = [
{ label: "Översikt", href: "/portal" },
{ label: "Prenumerationer", href: "/portal/subscriptions" },
{ label: "Filer", href: "/portal/files" },
{ label: "Integrationer", href: "/portal/integrationer" },
{ label: "Inställningar", href: "/portal/settings" },
];
const ADMIN_NAV: NavItem[] = [
{ label: "Översikt", href: "/admin" },
{ label: "Källor", href: "/admin/sources" },
{ label: "Jobb", href: "/admin/jobs" },
{ label: "Kunder", href: "/admin/customers" },
];
export function PortalShell({
variant,
title,
children,
}: {
variant: "customer" | "admin";
title: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const nav = variant === "admin" ? ADMIN_NAV : CUSTOMER_NAV;
const [email, setEmail] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/me")
.then((r) => r.json())
.then((d) => setEmail(d?.user?.email ?? null))
.catch(() => {});
}, []);
async function logout() {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
setEmail(null);
}
return (
<div className="min-h-screen bg-background text-foreground">
{/* top bar */}
<div className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-background px-6">
<Link href="/" className="flex items-center gap-2">
<span className="text-lg font-semibold tracking-[-0.04em]">0pt</span>
<span className="inline-block h-1 w-1" style={{ backgroundColor: "var(--accent-red)" }} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{variant === "admin" ? "admin" : "portal"}
</span>
</Link>
<div className="flex items-center gap-4">
{email ? (
<>
<span className="font-mono text-[11px] text-muted-foreground">{email}</span>
<button onClick={logout} className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
Logga ut
</button>
</>
) : (
<Link href="/portal/settings" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
Logga in
</Link>
)}
</div>
</div>
<div className="mx-auto flex max-w-[80rem] flex-col gap-0 md:flex-row">
{/* sidebar */}
<aside className="shrink-0 border-b border-border md:w-56 md:border-b-0 md:border-r">
<nav className="flex flex-row gap-0 overflow-x-auto p-2 md:flex-col md:p-4">
{nav.map((n) => {
const active = pathname === n.href;
return (
<Link
key={n.href}
href={n.href}
className="border-l-2 px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors"
style={{
borderColor: active ? "var(--accent-red)" : "transparent",
color: active ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{n.label}
</Link>
);
})}
{variant === "customer" ? (
<Link href="/bestall" className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]">
Ny beställning
</Link>
) : (
<Link href="/portal" className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
← Kundvy
</Link>
)}
</nav>
</aside>
{/* content */}
<main className="min-w-0 flex-1 px-6 py-10">
<h1 className="text-3xl font-medium tracking-[-0.03em] md:text-4xl">{title}</h1>
<div className="mt-8">{children}</div>
</main>
</div>
</div>
);
}
export function StatusPill({ status }: { status: string }) {
const color =
status === "delivered" || status === "accepted" ? "var(--prov-3)"
: status === "in_production" || status === "review" ? "var(--accent-red)"
: "var(--muted-foreground)";
return (
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em]">
<span className="inline-block h-2 w-2" style={{ backgroundColor: color }} />
{status}
</span>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { getSql } from "@/lib/db";
export interface Subscription {
sub_id: string;
order_id: string;
cadence: string;
webhook_url: string | null;
status: string;
next_run_at: string | null;
created_at: string;
}
const mem = new Map<string, Subscription>();
export async function createSubscription(order_id: string, cadence: string, webhook_url: string | null): Promise<Subscription> {
const sql = getSql();
if (sql) {
const [row] = await sql<Subscription[]>`
INSERT INTO subscriptions (order_id, cadence, webhook_url, status)
VALUES (${order_id}, ${cadence}, ${webhook_url}, 'active')
RETURNING sub_id, order_id, cadence, webhook_url, status, next_run_at, now() AS created_at`;
return row;
}
const id = globalThis.crypto?.randomUUID?.() ?? `sub-${Math.floor(Math.random() * 1e9)}`;
const sub: Subscription = { sub_id: id, order_id, cadence, webhook_url, status: "active", next_run_at: null, created_at: new Date().toISOString() };
mem.set(id, sub);
return sub;
}
export async function listSubscriptions(): Promise<Subscription[]> {
const sql = getSql();
if (sql) {
return sql<Subscription[]>`SELECT sub_id, order_id, cadence, webhook_url, status, next_run_at, created_at FROM subscriptions ORDER BY created_at DESC LIMIT 100`;
}
return [...mem.values()];
}