diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..95de2d7
--- /dev/null
+++ b/app/admin/page.tsx
@@ -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 = {
+ draft: "quoted",
+ quoted: "in_production",
+ in_production: "review",
+ review: "accepted",
+ accepted: "delivered",
+};
+
+export default function AdminPage() {
+ const [orders, setOrders] = useState(null);
+ const [metrics, setMetrics] = useState(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 (
+
+ {error ? (
+
+ ) : !metrics || !orders ? (
+
+ ) : (
+ <>
+
+ {[
+ ["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]) => (
+
+ ))}
+
+
+
+ Orderkö
+ {orders.length === 0 ? (
+
+ ) : (
+
+
+ ID Flöde Status Summa Åtgärd
+
+ {orders.map((o) => (
+
+ {o.order_id.slice(0, 8)}…
+ {o.flow}
+
+ {formatSEK(o.total_ore / 100)}
+
+ {NEXT_STATUS[o.status] ? (
+ 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]}
+
+ ) : (
+ klar
+ )}
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/app/admin/sources/page.tsx b/app/admin/sources/page.tsx
new file mode 100644
index 0000000..1f9e92d
--- /dev/null
+++ b/app/admin/sources/page.tsx
@@ -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 = {
+ 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(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 (
+
+
+ Laglig sourcing. API > bulk > auktoriserad webb > licens. Verifiera villkor.
+
+ {error ? (
+
+ ) : !sources ? (
+
+ ) : (
+
+
+ Källa Marknad Åtkomst Licens Färskhet
+
+ {sources.map((s) => (
+
+ {s.name}
+ {s.market}
+
+
+ {s.access}
+
+ {s.licence}
+ {s.cadence}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/api/admin/metrics/route.ts b/app/api/admin/metrics/route.ts
new file mode 100644
index 0000000..1fd5a4e
--- /dev/null
+++ b/app/api/admin/metrics/route.ts
@@ -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 = {};
+ 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,
+ });
+}
diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts
new file mode 100644
index 0000000..ffab6f4
--- /dev/null
+++ b/app/api/subscriptions/route.ts
@@ -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 });
+}
diff --git a/app/portal/page.tsx b/app/portal/page.tsx
index e75e1c8..eb4b72a 100644
--- a/app/portal/page.tsx
+++ b/app/portal/page.tsx
@@ -94,6 +94,25 @@ export default function PortalPage() {
+
+ {[
+ ["Översikt", "/portal"],
+ ["Prenumerationer", "/portal/subscriptions"],
+ ["Filer", "/portal/files"],
+ ["Integrationer", "/portal/integrationer"],
+ ["Inställningar", "/portal/settings"],
+ ["Admin", "/admin"],
+ ].map(([l, h]) => (
+
+ {l}
+
+ ))}
+
+
(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 (
+
+
+
+
+ Datahantering & DPA
+
+ {[
+ ["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]) => (
+
+ ✓
+ {t}. {s}
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/portal/subscriptions/page.tsx b/app/portal/subscriptions/page.tsx
new file mode 100644
index 0000000..e28967d
--- /dev/null
+++ b/app/portal/subscriptions/page.tsx
@@ -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(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 (
+
+
+
Gör om en order till ett flöde
+
+ 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" />
+ setCadence(e.target.value)} className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground">
+ Veckovis
+ Dagligt
+
+
+ {busy ? "Skapar…" : "Skapa flöde"}
+
+
+
+
+
+ {error ?
: !subs ?
: subs.length === 0 ? (
+
+ ) : (
+
+
+ Order Kadens Status Webhook
+
+ {subs.map((s) => (
+
+ {s.order_id.slice(0, 8)}…
+ {s.cadence}
+
+ {s.webhook_url ?? "—"}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/components/portal-shell.tsx b/components/portal-shell.tsx
new file mode 100644
index 0000000..a2b352c
--- /dev/null
+++ b/components/portal-shell.tsx
@@ -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(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 (
+
+ {/* top bar */}
+
+
+
0pt
+
+
+ {variant === "admin" ? "admin" : "portal"}
+
+
+
+ {email ? (
+ <>
+ {email}
+
+ Logga ut
+
+ >
+ ) : (
+
+ Logga in
+
+ )}
+
+
+
+
+ {/* sidebar */}
+
+
+ {nav.map((n) => {
+ const active = pathname === n.href;
+ return (
+
+ {n.label}
+
+ );
+ })}
+ {variant === "customer" ? (
+
+ Ny beställning
+
+ ) : (
+
+ ← Kundvy
+
+ )}
+
+
+
+ {/* content */}
+
+ {title}
+ {children}
+
+
+
+ );
+}
+
+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 (
+
+
+ {status}
+
+ );
+}
diff --git a/lib/subscriptions-store.ts b/lib/subscriptions-store.ts
new file mode 100644
index 0000000..cbea605
--- /dev/null
+++ b/lib/subscriptions-store.ts
@@ -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();
+
+export async function createSubscription(order_id: string, cadence: string, webhook_url: string | null): Promise {
+ const sql = getSql();
+ if (sql) {
+ const [row] = await sql`
+ 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 {
+ const sql = getSql();
+ if (sql) {
+ return sql`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()];
+}