feat: admin Verktyg (live fordon + sanktioner lookup) + service-type pricing in configurator

/admin/verktyg uses live /api/vehicles (RDW) + /api/screen (OpenSanctions); added to admin nav. Configurator now has a Tjänst selector that applies each service's price multiplier (lib/services) to the live estimate + summary; service_type carried into the order so orders-store prices by service. tsc 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 02:17:18 +02:00
co-authored by Claude Opus 4.8
parent 56e117a6d6
commit 76b20e341d
5 changed files with 117 additions and 4 deletions
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { PortalShell } from "@/components/portal-shell";
export default function ToolsPage() {
return (
<PortalShell variant="admin" title="Verktyg">
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
Snabboppslag mot lagliga källor. Härkomst på varje träff.
</p>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<VehicleTool />
<ScreenTool />
</div>
</PortalShell>
);
}
function VehicleTool() {
const [plate, setPlate] = useState("");
const [res, setRes] = useState<Record<string, unknown> | null>(null);
const [busy, setBusy] = useState(false);
async function lookup() {
if (!plate.trim()) return;
setBusy(true); setRes(null);
try { setRes(await (await fetch(`/api/vehicles?plate=${encodeURIComponent(plate)}`)).json()); } catch { setRes({ error: true }); }
setBusy(false);
}
const v = (res?.vehicle ?? null) as { make?: string; model?: string; year?: string; reg_plate?: string } | null;
return (
<section className="border border-border p-6">
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Fordon — RDW (NL)</h2>
<span className="inline-block h-2 w-2" style={{ backgroundColor: "var(--prov-3)" }} />
</div>
<div className="mt-4 flex gap-2">
<input value={plate} onChange={(e) => setPlate(e.target.value)} placeholder="Registreringsnr (NL)" className="flex-1 border border-border bg-input px-3 py-2 font-mono text-xs uppercase outline-none focus:border-foreground" />
<button onClick={lookup} disabled={busy} className="bg-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">{busy ? "…" : "Slå upp"}</button>
</div>
<div className="mt-4 min-h-[60px] font-mono text-xs">
{res === null ? <span className="text-muted-foreground">—</span>
: v ? (
<div className="space-y-1">
<div>{v.make} {v.model}</div>
<div className="text-muted-foreground">{v.reg_plate} · {v.year ?? "—"} · källa RDW</div>
</div>
) : <span className="text-muted-foreground">Ingen träff (ej tillgänglig).</span>}
</div>
</section>
);
}
function ScreenTool() {
const [q, setQ] = useState("");
const [matches, setMatches] = useState<{ name: string; score: number; schema: string }[] | null>(null);
const [busy, setBusy] = useState(false);
async function screen() {
if (!q.trim()) return;
setBusy(true); setMatches(null);
try { const d = await (await fetch(`/api/screen?q=${encodeURIComponent(q)}`)).json(); setMatches(d.matches ?? []); } catch { setMatches([]); }
setBusy(false);
}
return (
<section className="border border-border p-6">
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Sanktioner / PEP — OpenSanctions</h2>
<span className="inline-block h-2 w-2" style={{ backgroundColor: "var(--prov-2)" }} />
</div>
<div className="mt-4 flex gap-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Namn eller bolag" className="flex-1 border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
<button onClick={screen} disabled={busy} className="bg-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">{busy ? "…" : "Skärma"}</button>
</div>
<div className="mt-4 min-h-[60px] font-mono text-xs">
{matches === null ? <span className="text-muted-foreground">—</span>
: matches.length === 0 ? <span style={{ color: "var(--prov-3)" }}>Inga träffar.</span>
: (
<ul className="space-y-1">
{matches.map((m, i) => (
<li key={i} className="flex justify-between gap-3">
<span>{m.name}</span>
<span className="text-muted-foreground">{m.schema}{m.score ? ` · ${Math.round(m.score * 100)}%` : ""}</span>
</li>
))}
</ul>
)}
<p className="mt-3 text-[10.5px] text-muted-foreground/70">Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar.</p>
</div>
</section>
);
}
+19 -3
View File
@@ -5,6 +5,7 @@ import { useMemo, useState, useEffect } from "react";
import { OrderStepper } from "@/components/order-stepper";
import { ProvenanceBadge } from "@/components/provenance-badge";
import { createOrder } from "@/lib/order-client";
import { SERVICES, serviceById } from "@/lib/services";
import {
MARKETS,
ENTITY_TYPES,
@@ -34,10 +35,11 @@ export default function ConfiguratorPage() {
const [frequency, setFrequency] = useState<Frequency>("Engångs");
const [confidence, setConfidence] = useState(92);
const [formats, setFormats] = useState<string[]>(["CSV", "Tabellvy (NocoDB)"]);
const [service, setService] = useState("data");
const est = useMemo(
() => computeEstimate({ markets, type, fieldIds, enrichmentIds, volume, frequency, confidence }),
[markets, type, fieldIds, enrichmentIds, volume, frequency, confidence],
() => computeEstimate({ markets, type, fieldIds, enrichmentIds, volume, frequency, confidence, serviceMult: serviceById(service)?.mult ?? 1 }),
[markets, type, fieldIds, enrichmentIds, volume, frequency, confidence, service],
);
// Persist so the deposit screen shows the same numbers.
@@ -55,7 +57,7 @@ export default function ConfiguratorPage() {
async function submit() {
if (creating) return;
setCreating(true);
const config = { markets, type, fieldIds, enrichmentIds, volume, frequency, confidence, formats };
const config = { markets, type, fieldIds, enrichmentIds, volume, frequency, confidence, formats, service_type: service };
const id = await createOrder(frequency === "Engångs" ? "custom" : "subscription", config);
router.push(id ? `/bestall/forskott?order=${encodeURIComponent(id)}` : "/bestall/forskott");
}
@@ -129,6 +131,19 @@ export default function ConfiguratorPage() {
<div className="mt-12 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
{/* LEFT — builder */}
<div className="reveal-left flex flex-col gap-4">
<Block index="00" label="Tjänst">
<div className="flex flex-wrap gap-2">
{SERVICES.map((s) => (
<Chip key={s.id} active={service === s.id} onClick={() => setService(s.id)}>
{s.name}
</Chip>
))}
</div>
{serviceById(service) ? (
<p className="mt-3 text-xs text-muted-foreground">{serviceById(service)!.covers}</p>
) : null}
</Block>
<Block index="01" label="Marknader">
<div className="flex flex-wrap gap-2">
{MARKETS.map((m) => (
@@ -280,6 +295,7 @@ export default function ConfiguratorPage() {
</div>
<dl className="divide-y divide-border">
{[
["Tjänst", serviceById(service)?.name ?? service],
["Marknader", markets.join(" · ") || "—"],
["Typ", type],
["Fält", String(FIELDS.filter((f) => f.locked || fieldIds.includes(f.id)).length)],
+1
View File
@@ -18,6 +18,7 @@ const ADMIN_NAV: NavItem[] = [
{ label: "Översikt", href: "/admin" },
{ label: "Källor", href: "/admin/sources" },
{ label: "Jobb", href: "/admin/jobs" },
{ label: "Verktyg", href: "/admin/verktyg" },
{ label: "Kunder", href: "/admin/customers" },
];
+3 -1
View File
@@ -99,6 +99,7 @@ export interface EstimateInput {
volume: number;
frequency: Frequency;
confidence: number; // target 80–99
serviceMult?: number; // service-type price multiplier (see lib/services)
}
export interface Estimate {
@@ -118,7 +119,8 @@ export function computeEstimate(input: EstimateInput): Estimate {
const confidenceMult = 1 + Math.max(0, input.confidence - 90) * 0.03;
const enrichWeight = ENRICHMENTS.filter((e) => input.enrichmentIds.includes(e.id)).reduce((s, e) => s + e.weight, 0);
const base = fieldWeight * markets * FIELD_RATE * (input.volume / 1000) * entityMult * confidenceMult;
const serviceMult = input.serviceMult ?? 1;
const base = fieldWeight * markets * FIELD_RATE * (input.volume / 1000) * entityMult * confidenceMult * serviceMult;
const price = Math.round((base * (1 + enrichWeight)) / 5) * 5; // round to 5 kr
const deposit = Math.round(price / 2);
+3
View File
@@ -1,5 +1,6 @@
import { getSql } from "@/lib/db";
import { computeEstimate, type EntityType, type Frequency } from "@/lib/data";
import { serviceById } from "@/lib/services";
export interface OrderConfig {
markets?: string[];
@@ -10,6 +11,7 @@ export interface OrderConfig {
frequency?: Frequency;
confidence?: number;
formats?: string[];
service_type?: string;
}
export interface Order {
@@ -37,6 +39,7 @@ function estimateFor(config: OrderConfig) {
volume: config.volume ?? 1000,
frequency: config.frequency ?? "Engångs",
confidence: config.confidence ?? 92,
serviceMult: serviceById(config.service_type ?? "")?.mult ?? 1,
});
}