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]) => ( +
+

{l}

+

{v}

+
+ ))} +
+ +
+

Orderkö

+ {orders.length === 0 ? ( +
+ ) : ( +
+
+ IDFlödeStatusSummaÅtgärd +
+ {orders.map((o) => ( +
+ {o.order_id.slice(0, 8)}… + {o.flow} + + {formatSEK(o.total_ore / 100)} + + {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ällaMarknadÅtkomstLicensFä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() {

+ + (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 ( + +
+

Konto

+ {email ? ( +
+ Inloggad som {email} + +
+ ) : ( +
+ 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" /> + 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" /> + + {msg ? {msg} : null} +
+ )} +

+ Skarp inloggning (Auth.js + OAuth) aktiveras när databasen är kopplad. Dev-login kräver AUTH_SECRET + DEV_LOGIN_KEY. +

+
+ +
+

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" /> + + +
+
+ +
+ {error ? : !subs ? : subs.length === 0 ? ( + + ) : ( +
+
+ OrderKadensStatusWebhook +
+ {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 in + + )} +
+
+ +
+ {/* sidebar */} + + + {/* 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()]; +}