Ärendestart via arbetsorderskanning med konfidensgranskning
Primärvägen när ett ärende startas: teknikern fotar arbetsorderns framsida, orkesterns nya dokumenttolkningsuppgift (Sonnet 5, vision) läser dokumentet layoutoberoende och returnerar strukturerade fält (kund, fordon, verkstad, felbeskrivning) med konfidens per värde. - Konfidensvalidering: ≥95 % godkänns automatiskt, 80–95 % markeras för genomläsning, <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält - Visuell granskning: dokumentet bredvid fälten; klick på ett fält markerar ungefärlig position i bilden - "Starta diagnos" skapar hela ärendet: objekt ur fordonsfälten, metodikval ur felbeskrivningen, tolkningen loggad som ny organisationsintern händelse arbetsorder_skannad (filtreras ur kund- och partnerdelningar i server, RPC och klient) - Schema-bunden vision-uppgift i båda orkestertjänsterna; bild som data-URL, validerad på servern - Inloggade användare tillfrågas aldrig om namn — kontot används - Manuell inmatning kvar som andrahandsväg; tydligt märkt demo-tolkning i lokalt läge - 37 vitest-tester; integrationstestet verifierar filtreringen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -1014,7 +1014,7 @@ function RapportFlik({
|
||||
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
|
||||
// och ingår inte i det som delas med kund.
|
||||
const kundposter = arende.handelser.filter(
|
||||
(p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"].includes(p.handelse.typ),
|
||||
(p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"].includes(p.handelse.typ),
|
||||
);
|
||||
|
||||
// Alla exporter bygger på samma händelselogg och versionsmärks:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFelsokning, metodikForArende } from "@/felsokning/store";
|
||||
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
|
||||
@@ -228,6 +228,12 @@ export default function Arendelista() {
|
||||
const [namn, setNamn] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("alla");
|
||||
|
||||
// Inloggad användare tillfrågas aldrig om namn — kontot vet redan.
|
||||
const kontoNamn = plattformKonto()?.namn;
|
||||
useEffect(() => {
|
||||
if (!anvandare && kontoNamn) sattAnvandare(kontoNamn);
|
||||
}, [anvandare, kontoNamn, sattAnvandare]);
|
||||
|
||||
if (!anvandare) {
|
||||
return (
|
||||
<FelsokningSkal rubrik="Guidad Felsökning">
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { Objekt } from "@/felsokning/domain";
|
||||
import { useFelsokning } from "@/felsokning/store";
|
||||
import { valjMetodik } from "@/felsokning/metodik";
|
||||
import { valjMetodikMedAi } from "@/felsokning/ai";
|
||||
import { tolkaArbetsorder, valjMetodikMedAi, type TolkatFalt, type ArbetsorderGrupp } from "@/felsokning/ai";
|
||||
import { byggDemoTolkning } from "@/felsokning/demo";
|
||||
import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar";
|
||||
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||
import { skalaNerFoto } from "@/felsokning/format";
|
||||
|
||||
// Ingen felsökning börjar innan objektet identifierats och bekräftats.
|
||||
// Vilka objekttyper och identifieringsmetoder som visas styrs av
|
||||
// organisationens inställningar (systemadmin) — enhetens val i lokalt läge.
|
||||
// Ärendestart: teknikern är redan känd (inloggning/namn) och ska kunna
|
||||
// starta ett ärende på under 15 sekunder. Primärvägen är att fotografera
|
||||
// arbetsordern — systemet läser dokumentet och fyller i fälten; teknikern
|
||||
// granskar bara osäkra värden. Manuell inmatning finns som andrahandsväg,
|
||||
// styrd av organisationens inställningar.
|
||||
export default function NyttArende() {
|
||||
const navigate = useNavigate();
|
||||
const skapaArende = useFelsokning((s) => s.skapaArende);
|
||||
const laggTill = useFelsokning((s) => s.laggTill);
|
||||
|
||||
const [inst, setInst] = useState(lastaInstallningar());
|
||||
const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera");
|
||||
const [steg, setSteg] = useState<"start" | "granska" | "identifiera" | "bekrafta" | "felbeskrivning">("start");
|
||||
const [metod, setMetod] = useState(inst.identifieringsmetoder[0]);
|
||||
const [typ, setTyp] = useState(inst.objekttyper[0]);
|
||||
|
||||
@@ -32,6 +37,69 @@ export default function NyttArende() {
|
||||
const [fel, setFel] = useState("");
|
||||
const [startar, setStartar] = useState(false);
|
||||
|
||||
const [tolkning, setTolkning] = useState<{ foto: string; falt: TolkatFalt[]; modell?: string; demo?: boolean } | null>(null);
|
||||
const [skannar, setSkannar] = useState(false);
|
||||
const [skanningsFel, setSkanningsFel] = useState("");
|
||||
const skanRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Foto → dokumenttolkning i plattformens orkester. I lokalt läge (ingen
|
||||
// inloggning) visas en tydligt märkt demo-tolkning så flödet kan provas.
|
||||
const skanna = async (fil: File) => {
|
||||
setSkannar(true);
|
||||
setSkanningsFel("");
|
||||
const foto = await skalaNerFoto(fil);
|
||||
try {
|
||||
const resultat = await tolkaArbetsorder(foto);
|
||||
if (resultat) {
|
||||
if (resultat.falt.length === 0) {
|
||||
setSkanningsFel("Inga fält kunde läsas ur bilden — ta ett tydligare foto eller fyll i manuellt.");
|
||||
} else {
|
||||
setTolkning({ foto, falt: resultat.falt, modell: resultat.modell });
|
||||
setSteg("granska");
|
||||
}
|
||||
} else {
|
||||
setTolkning({ foto, falt: byggDemoTolkning(), demo: true });
|
||||
setSteg("granska");
|
||||
}
|
||||
} catch {
|
||||
setSkanningsFel("Dokumenttolkningen kunde inte nås — försök igen eller fyll i manuellt.");
|
||||
}
|
||||
setSkannar(false);
|
||||
};
|
||||
|
||||
// Granskade fält → ärende: objektet byggs ur fordonsfälten, kundens
|
||||
// felbeskrivning startar metodiken och hela tolkningen loggas som en
|
||||
// organisationsintern händelse (delas aldrig i kund-/partnervyer).
|
||||
const startaDiagnos = async (falt: TolkatFalt[]) => {
|
||||
if (!tolkning) return;
|
||||
setStartar(true);
|
||||
const v = (id: string) => falt.find((f) => f.id === id && f.varde.trim())?.varde.trim();
|
||||
const regnr = v("fordon_regnr");
|
||||
const vin = v("fordon_vin");
|
||||
const beskr = [v("fordon_marke"), v("fordon_modell"), v("fordon_arsmodell")].filter(Boolean).join(" ");
|
||||
const objektet: Objekt = {
|
||||
typ: inst.objekttyper.includes("Personbil") ? "Personbil" : inst.objekttyper[0],
|
||||
identifierare: (regnr ?? vin ?? "OKÄND").toUpperCase(),
|
||||
identifieringsmetod: regnr ? "Regnr" : vin ? "VIN" : "Manuell inmatning",
|
||||
beskrivning: beskr || "Se arbetsorder",
|
||||
kund: v("kund_namn"),
|
||||
};
|
||||
const felText = v("felbeskrivning") ?? "Se skannad arbetsorder";
|
||||
let metodikId: string | undefined;
|
||||
if (valjMetodik(felText).id === "generisk") {
|
||||
metodikId = (await valjMetodikMedAi(felText)) ?? undefined;
|
||||
}
|
||||
const id = skapaArende(objektet, felText, metodikId);
|
||||
laggTill(id, {
|
||||
typ: "arbetsorder_skannad",
|
||||
falt: falt
|
||||
.filter((f) => f.varde.trim())
|
||||
.map(({ id: faltId, etikett, varde, konfidens }) => ({ id: faltId, etikett, varde, konfidens })),
|
||||
dataUrl: tolkning.foto,
|
||||
});
|
||||
navigate(`/felsokning/arende/${id}`);
|
||||
};
|
||||
|
||||
const objektet: Objekt = {
|
||||
typ,
|
||||
identifierare: identifierare.trim().toUpperCase(),
|
||||
@@ -40,6 +108,68 @@ export default function NyttArende() {
|
||||
kund: kund.trim() || undefined,
|
||||
};
|
||||
|
||||
if (steg === "start") {
|
||||
return (
|
||||
<FelsokningSkal rubrik="Starta ärende" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||
<Panel rubrik="Skanna arbetsorder">
|
||||
<p className="mb-3 text-[#333333]">
|
||||
Fota arbetsorderns framsida. Systemet läser kund-, fordons- och ärendeuppgifter automatiskt —
|
||||
du granskar bara osäkra fält och trycker Starta diagnos.
|
||||
</p>
|
||||
<input
|
||||
ref={skanRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const fil = e.target.files?.[0];
|
||||
if (fil) skanna(fil);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{skannar ? (
|
||||
<p className="animate-pulse py-2 text-center font-semibold text-[#00437A]">Tolkar dokumentet …</p>
|
||||
) : (
|
||||
<StorKnapp onClick={() => skanRef.current?.click()}>📷 Skanna arbetsorder</StorKnapp>
|
||||
)}
|
||||
{skanningsFel && <p className="mt-2 font-semibold text-[#8B1A1A]">{skanningsFel}</p>}
|
||||
</Panel>
|
||||
<Panel>
|
||||
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
|
||||
Fyll i manuellt
|
||||
</StorKnapp>
|
||||
</Panel>
|
||||
</FelsokningSkal>
|
||||
);
|
||||
}
|
||||
|
||||
if (steg === "granska" && tolkning) {
|
||||
return (
|
||||
<FelsokningSkal
|
||||
bred
|
||||
rubrik="Granska tolkad arbetsorder"
|
||||
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
|
||||
>
|
||||
{tolkning.demo && (
|
||||
<p className="mb-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
|
||||
Demo-tolkning — plattformens dokumenttolkning kräver inloggning. Värdena nedan är exempeldata.
|
||||
</p>
|
||||
)}
|
||||
<GranskaTolkning
|
||||
foto={tolkning.foto}
|
||||
falt={tolkning.falt}
|
||||
startar={startar}
|
||||
vidStart={startaDiagnos}
|
||||
avbryt={() => {
|
||||
setTolkning(null);
|
||||
setSteg("start");
|
||||
}}
|
||||
/>
|
||||
</FelsokningSkal>
|
||||
);
|
||||
}
|
||||
|
||||
if (steg === "identifiera") {
|
||||
return (
|
||||
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||
@@ -134,3 +264,127 @@ export default function NyttArende() {
|
||||
</FelsokningSkal>
|
||||
);
|
||||
}
|
||||
|
||||
// Visuell granskning: den skannade arbetsordern till vänster, tolkade
|
||||
// fält till höger. Konfidensen styr vad teknikern behöver göra:
|
||||
// 🟢 ≥ 95 % godkänns automatiskt
|
||||
// 🟡 80–95 % markerad för genomläsning
|
||||
// 🔴 < 80 % kräver aktiv bekräftelse (eller redigering)
|
||||
// Klick på ett fält markerar var i dokumentet värdet hittades.
|
||||
function GranskaTolkning({
|
||||
foto,
|
||||
falt,
|
||||
startar,
|
||||
vidStart,
|
||||
avbryt,
|
||||
}: {
|
||||
foto: string;
|
||||
falt: TolkatFalt[];
|
||||
startar: boolean;
|
||||
vidStart: (falt: TolkatFalt[]) => void;
|
||||
avbryt: () => void;
|
||||
}) {
|
||||
const [rader, setRader] = useState(falt.map((f) => ({ ...f, bekraftad: f.konfidens >= 0.8 })));
|
||||
const [markerad, setMarkerad] = useState<string | null>(null);
|
||||
|
||||
const obekraftade = rader.filter((r) => !r.bekraftad);
|
||||
const vald = rader.find((r) => r.id === markerad);
|
||||
const grupper: ArbetsorderGrupp[] = ["Kund", "Fordon", "Verkstad", "Ärende"];
|
||||
|
||||
const uppdatera = (id: string, andring: Partial<(typeof rader)[number]>) =>
|
||||
setRader((r) => r.map((rad) => (rad.id === id ? { ...rad, ...andring } : rad)));
|
||||
|
||||
return (
|
||||
<div className="lg:grid lg:grid-cols-2 lg:items-start lg:gap-3">
|
||||
<Panel rubrik="Skannad arbetsorder">
|
||||
<div className="relative">
|
||||
<img src={foto} alt="Skannad arbetsorder" className="w-full rounded border border-[#C6C6C6]" />
|
||||
{vald?.omrade && (
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-sm border-2 border-[#00437A] bg-[#00437A]/15"
|
||||
style={{
|
||||
left: `${vald.omrade.x * 100}%`,
|
||||
top: `${vald.omrade.y * 100}%`,
|
||||
width: `${vald.omrade.bredd * 100}%`,
|
||||
height: `${vald.omrade.hojd * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[#707070]">
|
||||
{vald?.omrade
|
||||
? `Markerat: ${vald.etikett} (ungefärlig position)`
|
||||
: "Klicka på ett fält för att se var i dokumentet värdet hittades."}
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<div>
|
||||
{grupper.map((grupp) => {
|
||||
const iGrupp = rader.filter((r) => r.grupp === grupp);
|
||||
if (iGrupp.length === 0) return null;
|
||||
return (
|
||||
<Panel key={grupp} rubrik={grupp}>
|
||||
{iGrupp.map((rad) => {
|
||||
const niva = rad.konfidens >= 0.95 ? "gron" : rad.konfidens >= 0.8 ? "gul" : "rod";
|
||||
return (
|
||||
<div
|
||||
key={rad.id}
|
||||
onClick={() => setMarkerad(rad.id)}
|
||||
className={`mb-1.5 cursor-pointer rounded border p-2 ${
|
||||
markerad === rad.id ? "border-[#00437A]" : "border-transparent"
|
||||
} ${niva === "gul" ? "bg-[#FFF8E1]" : niva === "rod" ? "bg-[#FBE9E9]" : "bg-white"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
|
||||
{rad.etikett}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
niva === "gron" ? "text-[#1E6B34]" : niva === "gul" ? "text-[#9A6700]" : "text-[#8B1A1A]"
|
||||
}`}
|
||||
>
|
||||
{niva === "gron" ? "✓" : `${Math.round(rad.konfidens * 100)} %`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
value={rad.varde}
|
||||
onChange={(e) => uppdatera(rad.id, { varde: e.target.value, bekraftad: true })}
|
||||
className="w-full rounded border border-[#ADADAD] bg-white px-2 py-1 text-[13px] focus:border-[#00437A] focus:outline-none"
|
||||
/>
|
||||
{niva === "rod" && !rad.bekraftad && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
uppdatera(rad.id, { bekraftad: true });
|
||||
}}
|
||||
className="whitespace-nowrap rounded border border-[#6E1414] bg-[#8B1A1A] px-3 py-1 text-[12px] font-semibold text-white"
|
||||
>
|
||||
Bekräfta
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
|
||||
{obekraftade.length > 0 && (
|
||||
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
|
||||
{obekraftade.length} osäkra fält kräver bekräftelse eller korrigering innan diagnosen startar.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={avbryt}>
|
||||
Skanna om
|
||||
</StorKnapp>
|
||||
<StorKnapp disabled={obekraftade.length > 0 || startar} onClick={() => vidStart(rader)}>
|
||||
{startar ? "Startar …" : "Starta diagnos"}
|
||||
</StorKnapp>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user