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