ECM v2.0 som subsystem: sex motorer, pre-diagnostik och ärendeidentitet

Regelmotorn är nu plattformens styrande subsystem — systemet kan inte
skriva en slutsats som ECM inte godkänt.

Sex motorer (src/felsokning/ecm.ts):
- Evidence: evidensposter ur loggen med nivå E0–E6, tekniker och
  deterministisk innehållshash
- Rule: dokumentationskrav + undantagsregeln med obligatorisk orsak
- Compliance: ärendetypen (garanti/goodwill/försäkring/reklamation/
  begagnatgaranti …) styr extra krav — claim, skadenummer, historik,
  miltal, bildbevis
- Validation: "Evidens saknas" i stället för antaganden (orkesterns
  grundprompt + projektioner + grind)
- Completion: utökad kvalitetsgrind som spärrar slutrapporten
- Traceability: spårbarhetspaket (regelversion, grindstatus per
  regel-id, evidensposter med hash) i varje export

Pre-Diagnostic Validation — metodiken låses upp först när:
- fordonshistoriken kontrollerats (eller motiverats: kvalitetsvarning),
  med orsakskedja för tidigare arbeten
- ingående mätarställning fotograferats (bildtolkningen föreslår värdet)
- kundens felbeskrivning verifierats
- tidiga observationer hanterats
Utgående mätarställning fotograferas inför avslut och blir obligatorisk
i grinden när ärendet stängs.

Ärendeidentitet (Case Identity): fordonsobjektet utökat med VIN, miltal,
AO-, claim- och skadenummer (läses ur arbetsordern) — registreras en
gång, synligt i identitetsraden i arbetsytan (med ärendetypsval), låst
panel i Live Share, slutrapportens första sida och exporten.

49 vitest-tester; integrationstest och OpenAPI-validering gröna.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 09:18:43 +00:00
parent e8cf6db236
commit bfed5dbd7f
14 changed files with 943 additions and 163 deletions
+34 -1
View File
@@ -6,11 +6,39 @@
import type { Arende } from "./domain";
import { handelseRubrik } from "./domain";
import { arAvslutat, brief, foton, tidsfordelningsRader } from "./projektioner";
import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader } from "./projektioner";
import { metodikForArende } from "./store";
import { tidDatum, tidKlockslag } from "./format";
import { FelsokningSkal, Panel } from "./ui";
function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) {
const idn = arendeidentitet(arende);
const falt: [string, string | undefined][] = [
["Arbetsorder", idn.arbetsorder],
["Claim", idn.claim],
["Skadenr", idn.skadenummer],
["Regnr", idn.identifierare],
["VIN", idn.vin],
["Miltal", idn.miltal],
["Ansvarig tekniker", idn.ansvarig],
["Status", avslutat ? "Avslutat" : "Felsökning pågår"],
];
return (
<div className="sticky top-11 z-10 mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 shadow-[0_1px_3px_rgba(0,0,0,0.1)] print:static">
{idn.beskrivning && <p className="text-[13px] font-semibold">{idn.beskrivning}</p>}
<p className="flex flex-wrap gap-x-4 gap-y-0.5 text-[12px]">
{falt
.filter(([, varde]) => varde)
.map(([etikett, varde]) => (
<span key={etikett} className="whitespace-nowrap">
<span className="text-[#707070]">{etikett}:</span> <span className="font-medium">{varde}</span>
</span>
))}
</p>
</div>
);
}
export function DelatArendeVy({
arende,
nu,
@@ -48,6 +76,11 @@ export function DelatArendeVy({
</span>
}
>
{/* Ärendeidentiteten: låst överst — mottagaren (kund, försäkrings-
handläggare, kollega) ska aldrig tveka om vilket fordon och
ärende vyn avser. Härledd ur det nivåfiltrerade underlaget. */}
<IdentitetsPanel arende={arende} avslutat={avslutat} />
<p className="mb-4 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#4A5560] print:hidden">
{notis}
</p>
+130 -47
View File
@@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest";
import type { Arende, Handelse } from "../domain";
import { nyLoggPost } from "../domain";
import { VIBRATION_METODIK } from "../metodik";
import { evidensNiva, grindGodkand, kvalitetsgrind } from "../ecm";
import {
arendetyp,
evidensNiva,
evidensposter,
grindGodkand,
kvalitetsgrind,
preDiagnostik,
sparbarhetspaket,
} from "../ecm";
import { arendeidentitet } from "../projektioner";
import { byggDemoArende } from "../demo";
function byggArende(handelser: Handelse[]): Arende {
@@ -16,10 +25,28 @@ function byggArende(handelser: Handelse[]): Arende {
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: { typ: "Personbil", identifierare: "ABC123", identifieringsmetod: "Regnr", beskrivning: "Volvo XC60" },
objekt: {
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60",
vin: "YV1DZ8256F2123456",
miltal: "8 432 mil",
arbetsorder: "AO-2496",
claim: "G-2026-00481",
skadenummer: "SK-77812",
},
};
describe("evidensnivåer", () => {
// Grundkontroller som gör pre-diagnostiken klar.
const PREDIAG: Handelse[] = [
{ typ: "historik_kontrollerad", kontrollerad: true },
{ typ: "matarstallning", lage: "ingaende", varde: "84 320 km" },
{ typ: "kommentar", text: "Kundens felbeskrivning verifierad vid mottagandet." },
{ typ: "kommentar", text: "Inga ytterligare observationer vid mottagandet." },
];
describe("Evidence Engine", () => {
it("härleder nivån ur loggens faktiska innehåll", () => {
expect(evidensNiva(byggArende([]))).toBe("E0");
expect(evidensNiva(byggArende([{ typ: "observation", text: "x" }]))).toBe("E1");
@@ -35,25 +62,62 @@ describe("evidensnivåer", () => {
),
).toBe("E6");
});
it("katalogiserar evidensposter med tekniker, nivå och innehållshash", () => {
const arende = byggArende([
{ typ: "foto", beskrivning: "Höger framhjul", dataUrl: "data:x" },
{ typ: "matvarde", beskrivning: "Obalans", varde: "38", enhet: "g" },
]);
const poster = evidensposter(arende);
expect(poster).toHaveLength(2);
expect(poster[0].niva).toBe("E2");
expect(poster[1].sammanfattning).toBe("Obalans = 38 g");
expect(poster[0].tekniker).toBe("Anna");
expect(poster[0].hash).toMatch(/^[0-9a-f]{8}$/);
// Samma innehåll → samma hash (deterministisk spårbarhet).
expect(evidensposter(arende)[0].hash).toBe(poster[0].hash);
});
});
describe("kvalitetsgrind före slutrapport", () => {
describe("Pre-Diagnostic Validation", () => {
it("ingen felsökning utan grundkontroller — allt härleds ur loggen", () => {
const tomt = byggArende([OBJEKT]);
expect(preDiagnostik(tomt).every((r) => r.klar)).toBe(false);
const klart = byggArende([OBJEKT, ...PREDIAG]);
expect(preDiagnostik(klart).every((r) => r.klar)).toBe(true);
});
it("ej kontrollerad historik godtas med orsak men flaggas som kvalitetsvarning", () => {
const arende = byggArende([
OBJEKT,
{ typ: "historik_kontrollerad", kontrollerad: false, kommentar: "Historiksystemet otillgängligt" },
]);
const rad = preDiagnostik(arende).find((r) => r.id === "historik")!;
expect(rad.klar).toBe(true);
expect(rad.varning).toContain("Historiksystemet otillgängligt");
});
});
describe("Completion Engine (kvalitetsgrind)", () => {
it("utan evidens är grinden stängd — inga påståenden utan underlag", () => {
const tomt = byggArende([]);
expect(grindGodkand(tomt, VIBRATION_METODIK)).toBe(false);
const rader = kvalitetsgrind(tomt, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "objekt")?.ok).toBe(false);
expect(rader.find((r) => r.id === "kontroller")?.ok).toBe(false);
expect(rader.find((r) => r.id === "kontroller")?.detalj).toContain("Evidens saknas");
});
it("ett dokumenterat undantag räknas som hanterat men flaggas med orsak", () => {
// Alla kontroller får undantag med orsak — grinden öppnas, men
// undantagen redovisas i detaljraden (aldrig som "utförd").
const undantag: Handelse[] = [OBJEKT, { typ: "foto", beskrivning: "Översikt", dataUrl: "data:" }, { typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", enhet: "bar" }];
it("dokumenterade undantag öppnar grinden men redovisas med orsak", () => {
const handelser: Handelse[] = [
OBJEKT,
...PREDIAG,
{ typ: "foto", beskrivning: "Översikt", dataUrl: "data:" },
{ typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", enhet: "bar" },
];
for (const steg of VIBRATION_METODIK.steg) {
for (const kontroll of steg.kontroller ?? []) {
undantag.push({
handelser.push({
typ: "kontroll_utford",
stegId: steg.id,
kontrollId: kontroll.id,
@@ -62,47 +126,66 @@ describe("kvalitetsgrind före slutrapport", () => {
});
}
}
const arende = byggArende(undantag);
const arende = byggArende(handelser);
expect(grindGodkand(arende, VIBRATION_METODIK)).toBe(true);
const rad = kvalitetsgrind(arende, VIBRATION_METODIK).find((r) => r.id === "kontroller");
expect(rad?.ok).toBe(true);
expect(rad?.detalj).toContain("Undantag med orsak");
expect(rad?.detalj).toContain("Kunden avböjde demontering");
});
it("demoärendet blockeras tills alla kontroller har evidens eller undantag", () => {
const demo = byggDemoArende(1);
expect(grindGodkand(demo, VIBRATION_METODIK)).toBe(false);
const komplett: Arende = {
...demo,
handelser: [...demo.handelser],
};
const hanterade = new Set(
demo.handelser
.filter((p) => p.handelse.typ === "kontroll_utford")
.map((p) => {
const h = p.handelse;
return h.typ === "kontroll_utford" ? `${h.stegId}/${h.kontrollId}` : "";
}),
);
for (const steg of VIBRATION_METODIK.steg) {
for (const kontroll of steg.kontroller ?? []) {
if (!hanterade.has(`${steg.id}/${kontroll.id}`)) {
komplett.handelser.push(
nyLoggPost("Johan", {
typ: "kontroll_utford",
stegId: steg.id,
kontrollId: kontroll.id,
text: kontroll.text,
undantag: "Utrustning saknas",
}),
);
}
}
}
expect(grindGodkand(komplett, VIBRATION_METODIK)).toBe(true);
// Demoärendet har foto + mätvärden + flera källor → högsta evidensnivån.
expect(evidensNiva(komplett)).toBe("E6");
it("utgående mätarställning blir obligatorisk när ärendet avslutas", () => {
const bas: Handelse[] = [OBJEKT, ...PREDIAG];
const oppen = byggArende(bas);
expect(kvalitetsgrind(oppen, VIBRATION_METODIK).find((r) => r.id === "matarstallning_ut")?.kravs).toBe(false);
const stangd = byggArende([...bas, { typ: "arende_avslutat" }]);
expect(kvalitetsgrind(stangd, VIBRATION_METODIK).find((r) => r.id === "matarstallning_ut")?.kravs).toBe(true);
});
});
describe("Compliance Engine", () => {
it("ärendetypen styr dokumentationskraven", () => {
const privat = byggArende([OBJEKT]);
expect(arendetyp(privat)).toBe("Privat kund");
expect(kvalitetsgrind(privat, VIBRATION_METODIK).some((r) => r.id.startsWith("garanti_"))).toBe(false);
const garanti = byggArende([OBJEKT, { typ: "arendetyp_satt", arendetyp: "Garanti" }]);
const rader = kvalitetsgrind(garanti, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "garanti_claim")?.ok).toBe(true); // claim finns i objektet
expect(rader.find((r) => r.id === "garanti_historik")?.ok).toBe(false); // historik ej kontrollerad
});
it("försäkringsärenden kräver skadenummer och bildbevis", () => {
const utanBild = byggArende([OBJEKT, { typ: "arendetyp_satt", arendetyp: "Försäkring" }]);
const rader = kvalitetsgrind(utanBild, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "forsakring_skadenummer")?.ok).toBe(true);
expect(rader.find((r) => r.id === "forsakring_bildbevis")?.ok).toBe(false);
});
});
describe("Traceability Engine + ärendeidentitet", () => {
it("spårbarhetspaketet innehåller version, grindstatus och evidensposter", () => {
const arende = byggArende([OBJEKT, { typ: "foto", beskrivning: "x", dataUrl: "data:" }]);
const paket = sparbarhetspaket(arende, VIBRATION_METODIK);
expect(paket.ecmVersion).toBe("2.0");
expect(paket.arendetyp).toBe("Privat kund");
expect(paket.evidensposter).toHaveLength(1);
expect(paket.kvalitetsgrind.some((r) => r.id === "objekt" && r.ok)).toBe(true);
});
it("ärendeidentiteten registreras en gång och återanvänds överallt", () => {
const arende = byggArende([OBJEKT, { typ: "matarstallning", lage: "ingaende", varde: "84 320 km" }]);
const idn = arendeidentitet(arende);
expect(idn.arbetsorder).toBe("AO-2496");
expect(idn.claim).toBe("G-2026-00481");
expect(idn.skadenummer).toBe("SK-77812");
expect(idn.vin).toBe("YV1DZ8256F2123456");
// Den fotograferade mätarställningen ersätter arbetsorderns uppgift.
expect(idn.miltal).toBe("84 320 km");
});
it("demoärendet passerar pre-diagnostiken men blockeras av okontrollerade metodiksteg", () => {
const demo = byggDemoArende(1);
expect(preDiagnostik(demo).every((r) => r.klar)).toBe(true);
expect(grindGodkand(demo, VIBRATION_METODIK)).toBe(false);
expect(evidensNiva(demo)).toBe("E6");
});
});
+2
View File
@@ -128,6 +128,8 @@ export const ARBETSORDER_FALT: { id: string; grupp: ArbetsorderGrupp; etikett: s
{ id: "fordon_motorkod", grupp: "Fordon", etikett: "Motorkod" },
{ id: "fordon_vaxellada", grupp: "Fordon", etikett: "Växellåda" },
{ id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" },
{ id: "ao_claim", grupp: "Verkstad", etikett: "Claim-/garantinr" },
{ id: "ao_skadenummer", grupp: "Verkstad", etikett: "Skadenummer" },
{ id: "ao_referens", grupp: "Verkstad", etikett: "Intern referens" },
{ id: "ao_serviceradgivare", grupp: "Verkstad", etikett: "Servicerådgivare" },
{ id: "ao_bokningsdatum", grupp: "Verkstad", etikett: "Bokningsdatum" },
+9
View File
@@ -63,9 +63,18 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
vin: "YV1DZ8256F2123456",
miltal: "8 432 mil",
arbetsorder: "AO-2496",
},
}),
post(2, { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h. Symptomet uppträder endast under körning." }),
// Pre-diagnostiken: historik, ingående mätarställning, verifierad
// felbeskrivning och tidiga observationer — innan metodiken börjar.
post(3, { typ: "historik_kontrollerad", kontrollerad: true, kommentar: "Däck roterade vid service för 2 månader sedan" }),
post(3, { typ: "matarstallning", lage: "ingaende", varde: "84 320 km", dataUrl: demoFoto("Instrumentpanel 84 320 km") }),
post(4, { typ: "kommentar", text: "Kundens felbeskrivning verifierad vid mottagandet." }),
post(4, { typ: "kommentar", text: "Inga ytterligare observationer vid mottagandet." }),
post(4, { typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "Är vibrationen hastighetsberoende?", svar: "Ja" }),
post(5, { typ: "fraga_besvarad", stegId: "symptom", frageId: "var", fraga: "Var känns vibrationen?", svar: "I ratten" }),
post(6, { typ: "fraga_besvarad", stegId: "symptom", frageId: "nar", fraga: "När uppstår vibrationen?", svar: "Vid jämn fart" }),
+27 -1
View File
@@ -29,12 +29,21 @@ export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
paus: "Paus",
};
// Fordonsobjektet är den röda tråden genom hela ärendet: identiteten
// registreras en gång och återanvänds i felsökning, Live Share, rapport
// och export. Ärendereferenserna (AO/claim/skadenummer) hör till objektet
// så att de följer med i alla vyer utan att kunduppgifter läcker.
export interface Objekt {
typ: string; // t.ex. "Fordon", "Industrimaskin"
typ: string; // t.ex. "Personbil", "Industrimaskin"
identifierare: string; // reg.nr, VIN, serienummer …
identifieringsmetod: string; // "Regnr", "VIN", "Serienummer", "Manuell inmatning"
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
kund?: string;
vin?: string;
miltal?: string;
arbetsorder?: string;
claim?: string;
skadenummer?: string;
}
// Ett fält tolkat ur en skannad arbetsorder, med AI:ns läs-säkerhet.
@@ -63,6 +72,14 @@ export type Handelse =
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
| { typ: "overlamning"; fran: string; till?: string }
| { typ: "ansvarig_satt"; ansvarig: string }
// Ärendetypen styr vilka dokumentationskrav ECM ställer (garanti,
// försäkring, reklamation …).
| { typ: "arendetyp_satt"; arendetyp: string }
// Pre-diagnostik: fordonshistoriken kontrollerad — eller motiverat
// varför inte (kvalitetsvarning).
| { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string }
// Officiell mätarställning in/ut, normalt med foto av instrumentpanelen.
| { typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; dataUrl?: string; undantag?: string }
| { typ: "export_skapad"; format: string; version: number }
| {
typ: "ai_svar";
@@ -136,6 +153,15 @@ export function handelseRubrik(post: LoggPost): string {
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
case "ansvarig_satt":
return `Ansvarig tekniker: ${h.ansvarig}`;
case "arendetyp_satt":
return `Ärendetyp: ${h.arendetyp}`;
case "historik_kontrollerad":
return h.kontrollerad
? `Fordonshistorik kontrollerad${h.kommentar ? `: ${h.kommentar}` : ""}`
: `Fordonshistorik EJ kontrollerad — orsak: ${h.kommentar ?? "saknas"}`;
case "matarstallning":
if (h.undantag) return `Mätarställning (${h.lage === "ingaende" ? "in" : "ut"}) kunde inte dokumenteras: ${h.undantag}`;
return `Mätarställning ${h.lage === "ingaende" ? "in" : "ut"}: ${h.varde}`;
case "export_skapad":
return `Export skapad: ${h.format}, version ${h.version}`;
case "ai_svar": {
+293 -35
View File
@@ -1,22 +1,31 @@
// Evidence & Compliance Matrix (ECM) — plattformens regelmotor.
//
// Grundprincip: varje påstående måste kunna härledas till evidens i
// händelseloggen. Systemet får aldrig skriva "kontrollerad", "OK" eller
// "inga fel" utan underlag — utan evidens skrivs "Evidens saknas".
// ECM är ett eget subsystem, inte en tabell i databasen: den avgör vilken
// dokumentation som krävs, när dokumentation saknas, vilken bevisnivå som
// uppnåtts, vilka regler som gäller (per ärendetyp) och om ett ärende kan
// avslutas. Systemet kan aldrig skriva en slutsats som ECM inte godkänt.
//
// Regelbiblioteket är versionshanterat och skilt från applikationslogiken:
// evidensnivåer, krav och kvalitetsgrind ändras här (och i kommande
// versioner via serverdistribuerade regler) utan att vyerna byggs om.
// Vyerna anropar bara de rena funktionerna nedan.
// Sex motorer:
// 1. Evidence Engine — katalogiserar all bevisning ur loggen
// 2. Rule Engine — dokumentationskraven (metodik + automatiska regler)
// 3. Compliance Engine — vilka regler som gäller per ärendetyp
// 4. Validation Engine — inga påståenden utan underlag ("Evidens saknas")
// 5. Completion Engine — kvalitetsgrind: får ärendet avslutas/rapporteras?
// 6. Traceability Engine — varje evidenspost hash:as och versionsmärks
//
// Regelbiblioteket är versionshanterat och skilt från applikationslogiken;
// vyerna anropar bara de rena funktionerna nedan. Nästa steg är server-
// distribuerade regelpaket (garantivillkor per tillverkare, försäkrings-
// krav, reklamationsregler) som laddas dynamiskt utan appändring.
import type { Arende } from "./domain";
import type { Arende, LoggPost } from "./domain";
import type { Metodik } from "./metodik";
export const ECM_VERSION = "1.0";
export const ECM_VERSION = "2.0";
// ---- 1. Evidence Engine -----------------------------------------------
// Evidensnivåer: bevisvärdet för ett påstående, härlett ur loggen.
// E0 inget underlag · E1 teknikerns observation · E2 foto · E3 video
// E4 mätvärde · E5 diagnosdata/dokument · E6 flera oberoende källor
export type EvidensNiva = "E0" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
export const EVIDENS_LABEL: Record<EvidensNiva, string> = {
@@ -29,8 +38,76 @@ export const EVIDENS_LABEL: Record<EvidensNiva, string> = {
E6: "E6 · Flera oberoende källor",
};
// Godkända orsaker när underlag inte kan tas fram. Fri text tillåts
// också — men en orsak är alltid obligatorisk.
export interface Evidenspost {
id: string;
tidpunkt: string;
tekniker: string;
kategori: string;
niva: Exclude<EvidensNiva, "E0" | "E6">;
sammanfattning: string;
// Innehållshash för spårbarhet: samma post ger alltid samma hash,
// varje ändringsförsök syns (loggen är dessutom append-only i DB:n).
hash: string;
}
// FNV-1a — snabb, deterministisk innehållshash (spårbarhet, inte krypto;
// manipulationsskyddet ligger i databasens append-only-triggers).
export function innehallsHash(innehall: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < innehall.length; i++) {
h ^= innehall.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
// Katalogiserar varje evidenspost i loggen med nivå, kategori och hash.
export function evidensposter(arende: Arende): Evidenspost[] {
const poster: Evidenspost[] = [];
const lagg = (post: LoggPost, kategori: string, niva: Evidenspost["niva"], sammanfattning: string) =>
poster.push({
id: post.id,
tidpunkt: post.tidpunkt,
tekniker: post.anvandare,
kategori,
niva,
sammanfattning,
hash: innehallsHash(JSON.stringify(post.handelse)),
});
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning);
if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`);
if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`);
if (h.typ === "observation") lagg(post, "observation", "E1", h.text);
if (h.typ === "kontroll_utford" && !h.undantag) lagg(post, "kontroll", "E1", h.text);
}
return poster;
}
// Ärendets samlade evidensnivå: starkaste kombination loggen innehåller.
export function evidensNiva(arende: Arende): EvidensNiva {
const poster = evidensposter(arende);
const har = (n: string) => poster.some((p) => p.niva === n);
const kallor = [har("E2"), har("E4"), har("E5")].filter(Boolean).length;
if (kallor >= 2) return "E6";
if (har("E5")) return "E5";
if (har("E4")) return "E4";
if (har("E2")) return "E2";
if (har("E1")) return "E1";
return "E0";
}
// ---- 2. Rule Engine ---------------------------------------------------
// Automatiska regler (kodade i orkesterns grundprompt och i metodikens
// krav-fält): kan det fotograferas → begär foto; låter det → video med
// ljud; rör det sig → video; mäts det → mätvärde; visar en display
// informationen → fota displayen; finns ett dokument → fota dokumentet.
// Godkända orsaker när underlag inte kan tas fram (fri text tillåts
// också — men en orsak är alltid obligatorisk).
export const UNDANTAGSORSAKER = [
"Komponenten är oåtkomlig",
"Fordonet kan inte lyftas säkert",
@@ -39,29 +116,142 @@ export const UNDANTAGSORSAKER = [
"Utrustning saknas",
];
// Evidensnivå för ärendet som helhet: den starkaste kombination som
// loggen faktiskt innehåller.
export function evidensNiva(arende: Arende): EvidensNiva {
let foto = false;
let matvarde = false;
let dokument = false;
let observation = false;
// Markörtexter för pre-diagnostikens kvitteringar (loggas som kommentar).
export const MARKOR_FELBESKRIVNING_VERIFIERAD = "Kundens felbeskrivning verifierad vid mottagandet";
export const MARKOR_INGA_TIDIGA_OBSERVATIONER = "Inga ytterligare observationer vid mottagandet";
export const MARKOR_TIDIGA_OBSERVATIONER_KLARA = "Tidiga observationer vid mottagandet dokumenterade";
// ---- 3. Compliance Engine ---------------------------------------------
// Ärendetypen styr vilka dokumentationskrav som gäller utöver metodiken.
export const ARENDETYPER = [
"Privat kund",
"Företagskund",
"Garanti",
"Goodwill",
"Försäkring",
"Reklamation",
"Begagnatgaranti",
"Intern kvalitetskontroll",
"Teknisk utredning",
] as const;
export type Arendetyp = (typeof ARENDETYPER)[number];
export function arendetyp(arende: Arende): Arendetyp {
let typ: Arendetyp = "Privat kund";
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "foto") foto = true;
if (h.typ === "matvarde") matvarde = true;
if (h.typ === "arbetsorder_skannad") dokument = true;
if (h.typ === "observation" || h.typ === "kontroll_utford") observation = true;
if (h.typ === "arendetyp_satt" && (ARENDETYPER as readonly string[]).includes(h.arendetyp)) {
typ = h.arendetyp as Arendetyp;
}
}
const kallor = [foto, matvarde, dokument].filter(Boolean).length;
if (kallor >= 2) return "E6";
if (dokument) return "E5";
if (matvarde) return "E4";
if (foto) return "E2";
if (observation) return "E1";
return "E0";
return typ;
}
interface ComplianceKrav {
id: string;
rubrik: string;
uppfyllt: (arende: Arende) => boolean;
detaljVidBrist: string;
}
function objektFalt(arende: Arende, falt: "miltal" | "claim" | "skadenummer" | "arbetsorder"): string | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt[falt];
}
return undefined;
}
const harMatarstallning = (arende: Arende) =>
arende.handelser.some((p) => p.handelse.typ === "matarstallning" && !p.handelse.undantag) ||
!!objektFalt(arende, "miltal");
const harHistorik = (arende: Arende) =>
arende.handelser.some((p) => p.handelse.typ === "historik_kontrollerad" && p.handelse.kontrollerad);
const harFoto = (arende: Arende) => arende.handelser.some((p) => p.handelse.typ === "foto");
// Regelpaket per ärendetyp — här ansluter framtida serverdistribuerade
// regler (garantivillkor per tillverkare, försäkringsbolagens krav …).
const COMPLIANCE_REGLER: Partial<Record<Arendetyp, ComplianceKrav[]>> = {
Garanti: [
{ id: "garanti_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Garantiärenden kräver dokumenterad mätarställning." },
{ id: "garanti_historik", rubrik: "Servicehistorik kontrollerad", uppfyllt: harHistorik, detaljVidBrist: "Garantiärenden kräver kontrollerad servicehistorik." },
{ id: "garanti_claim", rubrik: "Claim-/garantinummer registrerat", uppfyllt: (a) => !!objektFalt(a, "claim"), detaljVidBrist: "Ange claim-/garantinummer (läses ur arbetsordern)." },
],
Goodwill: [
{ id: "goodwill_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Goodwillärenden kräver dokumenterad mätarställning." },
{ id: "goodwill_historik", rubrik: "Servicehistorik kontrollerad", uppfyllt: harHistorik, detaljVidBrist: "Goodwillärenden kräver kontrollerad servicehistorik." },
],
Försäkring: [
{ id: "forsakring_skadenummer", rubrik: "Skadenummer registrerat", uppfyllt: (a) => !!objektFalt(a, "skadenummer"), detaljVidBrist: "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ id: "forsakring_bildbevis", rubrik: "Bildbevis finns", uppfyllt: harFoto, detaljVidBrist: "Försäkringsärenden kräver bilddokumentation." },
],
Reklamation: [
{ id: "reklamation_historik", rubrik: "Historik och tidigare försök kontrollerade", uppfyllt: harHistorik, detaljVidBrist: "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." },
],
Begagnatgaranti: [
{ id: "begagnat_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Begagnatgaranti kräver dokumenterad mätarställning." },
],
};
// ---- 4. Validation Engine ---------------------------------------------
// Kärnprincipen — inga påståenden utan underlag — verkar i tre lager:
// (a) orkesterns grundprompt: aldrig "OK/kontrollerad/inga fel" utan
// evidens; skriv "Evidens saknas" och begär rätt underlag,
// (b) projektionerna: hypoteser kan aldrig bli konstaterade fel,
// (c) kvalitetsgrinden nedan: rapport/avslut blockeras tills varje
// obligatoriskt påstående har evidens eller dokumenterat undantag.
// ---- Pre-Diagnostic Validation ----------------------------------------
export interface PreDiagRad {
id: "historik" | "matarstallning_in" | "felbeskrivning" | "tidiga_observationer";
rubrik: string;
klar: boolean;
varning?: string;
}
// Ingen felsökning påbörjas förrän grundkontrollerna är genomförda —
// eller dokumenterat motiverade. Allt härleds ur loggen.
export function preDiagnostik(arende: Arende): PreDiagRad[] {
let historik: PreDiagRad = { id: "historik", rubrik: "Fordonshistorik kontrollerad", klar: false };
let matarstallning = false;
let felbeskrivningVerifierad = false;
let tidiga = false;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "historik_kontrollerad") {
historik = {
id: "historik",
rubrik: "Fordonshistorik kontrollerad",
klar: true,
varning: h.kontrollerad ? undefined : `Ej kontrollerad — orsak: ${h.kommentar ?? "saknas"}`,
};
}
if (h.typ === "matarstallning" && h.lage === "ingaende") matarstallning = true;
if (h.typ === "kommentar" && h.text.startsWith(MARKOR_FELBESKRIVNING_VERIFIERAD)) felbeskrivningVerifierad = true;
if (
h.typ === "kommentar" &&
(h.text.startsWith(MARKOR_INGA_TIDIGA_OBSERVATIONER) || h.text.startsWith(MARKOR_TIDIGA_OBSERVATIONER_KLARA))
) {
tidiga = true;
}
}
return [
historik,
{ id: "matarstallning_in", rubrik: "Ingående mätarställning dokumenterad", klar: matarstallning },
{ id: "felbeskrivning", rubrik: "Kundens felbeskrivning verifierad", klar: felbeskrivningVerifierad },
{ id: "tidiga_observationer", rubrik: "Tidiga observationer hanterade", klar: tidiga },
];
}
export function preDiagnostikKlar(arende: Arende): boolean {
return preDiagnostik(arende).every((rad) => rad.klar);
}
// ---- 5. Completion Engine ---------------------------------------------
export interface GrindRad {
id: string;
rubrik: string;
@@ -71,12 +261,13 @@ export interface GrindRad {
detalj?: string;
}
// Kvalitetsgrind före slutrapport: varje rad är en verifierbar kontroll
// mot händelseloggen. Rapporten kan inte genereras förrän alla
// obligatoriska rader är gröna — evidens eller dokumenterat undantag.
// Kvalitetsgrind före slutrapport/avslut. Varje rad är en verifierbar
// regel mot händelseloggen; regel-id och ECM-version följer med i
// exporten (Traceability Engine).
export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
const rader: GrindRad[] = [];
const handelser = arende.handelser.map((p) => p.handelse);
const avslutat = handelser.some((h) => h.typ === "arende_avslutat");
const objektFinns = handelser.some((h) => h.typ === "objekt_identifierat");
rader.push({
@@ -87,7 +278,7 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: objektFinns ? undefined : "Evidens saknas — identifiera objektet.",
});
const arbetsorder = handelser.some((h) => h.typ === "arbetsorder_skannad");
const arbetsorder = handelser.some((h) => h.typ === "arbetsorder_skannad") || !!objektFalt(arende, "arbetsorder");
rader.push({
id: "arbetsorder",
rubrik: "Arbetsorder inläst",
@@ -96,6 +287,44 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: arbetsorder ? undefined : "Ärendet startades utan skannad arbetsorder.",
});
// Pre-diagnostiken ingår i grinden: historik + ingående mätarställning
// är obligatoriska (dokumenterade eller motiverade).
const pre = preDiagnostik(arende);
const historik = pre.find((r) => r.id === "historik")!;
rader.push({
id: "historik",
rubrik: "Fordonshistorik kontrollerad eller motiverad",
ok: historik.klar,
kravs: true,
detalj: historik.klar ? historik.varning : "Kontrollera historiken eller dokumentera varför det inte gått.",
});
const matIn = pre.find((r) => r.id === "matarstallning_in")!;
rader.push({
id: "matarstallning_in",
rubrik: "Ingående mätarställning dokumenterad",
ok: matIn.klar,
kravs: true,
detalj: matIn.klar ? undefined : "Fotografera instrumentpanelen (eller dokumentera undantag).",
});
const felb = pre.find((r) => r.id === "felbeskrivning")!;
rader.push({
id: "felbeskrivning_verifierad",
rubrik: "Kundens felbeskrivning verifierad",
ok: felb.klar,
kravs: false,
detalj: felb.klar ? undefined : "Bekräfta att kundens beskrivning är korrekt återgiven.",
});
// Utgående mätarställning: obligatorisk först när ärendet avslutas.
const matUt = handelser.some((h) => h.typ === "matarstallning" && h.lage === "utgaende");
rader.push({
id: "matarstallning_ut",
rubrik: "Utgående mätarställning dokumenterad",
ok: matUt,
kravs: avslutat,
detalj: matUt ? undefined : "Fotografera instrumentpanelen när arbetet är klart.",
});
// Metodikens kontroller: evidens eller dokumenterat undantag per kontroll.
const hanterade = new Map<string, { undantag?: string }>();
for (const h of handelser) {
@@ -134,6 +363,19 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: fotoKravUtanFoto > 0 ? `${fotoKravUtanFoto} fotokrävande kontroll(er) utan bild i loggen.` : undefined,
});
// Compliance Engine: ärendetypens regelpaket.
const typ = arendetyp(arende);
for (const krav of COMPLIANCE_REGLER[typ] ?? []) {
const ok = krav.uppfyllt(arende);
rader.push({
id: krav.id,
rubrik: `${typ}: ${krav.rubrik}`,
ok,
kravs: true,
detalj: ok ? undefined : krav.detaljVidBrist,
});
}
const hypoteser = handelser.filter((h) => h.typ === "hypotes").length;
rader.push({
id: "hypoteser",
@@ -159,3 +401,19 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
export function grindGodkand(arende: Arende, metodik: Metodik): boolean {
return kvalitetsgrind(arende, metodik).every((rad) => rad.ok || !rad.kravs);
}
// ---- 6. Traceability Engine -------------------------------------------
// Spårbarhetspaketet som följer med varje export: regelverkets version,
// ärendets evidensnivå och samtliga evidensposter med innehållshash.
// Tillsammans med den append-only-loggen kan varje slutsats härledas:
// vilken bild, vilken mätning, vilken tekniker, vilken regel, när.
export function sparbarhetspaket(arende: Arende, metodik: Metodik) {
return {
ecmVersion: ECM_VERSION,
arendetyp: arendetyp(arende),
evidensniva: evidensNiva(arende),
kvalitetsgrind: kvalitetsgrind(arende, metodik).map(({ id, rubrik, ok, kravs }) => ({ id, rubrik, ok, kravs })),
evidensposter: evidensposter(arende),
};
}
+40
View File
@@ -33,6 +33,46 @@ export function ansvarig(arende: Arende): string | undefined {
return namn;
}
// Ärendeidentiteten (Case Identity): registreras en gång och återanvänds
// i felsökningsvyn, Live Share, slutrapporten och exporten. Fordons-
// objektet är den röda tråden — det ska alltid vara omedelbart tydligt
// vilket fordon och vilket ärende informationen avser.
export interface Arendeidentitet {
nummer: number;
arbetsorder?: string;
claim?: string;
skadenummer?: string;
identifierare?: string;
vin?: string;
beskrivning?: string;
miltal?: string;
kund?: string;
ansvarig?: string;
avslutat: boolean;
}
export function arendeidentitet(arende: Arende): Arendeidentitet {
const o = objekt(arende);
let miltal = o?.miltal;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "matarstallning" && h.lage === "ingaende" && !h.undantag) miltal = h.varde;
}
return {
nummer: arende.nummer,
arbetsorder: o?.arbetsorder,
claim: o?.claim,
skadenummer: o?.skadenummer,
identifierare: o?.identifierare,
vin: o?.vin,
beskrivning: o?.beskrivning,
miltal,
kund: o?.kund,
ansvarig: ansvarig(arende),
avslutat: arAvslutat(arende),
};
}
export function arAvslutat(arende: Arende): boolean {
return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat");
}
+308 -9
View File
@@ -6,6 +6,7 @@ import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik";
import {
arAvslutat,
arendeidentitet,
brief,
felbeskrivning,
formateraTid,
@@ -35,7 +36,19 @@ import {
type InstrumentTolkning,
} from "@/felsokning/ai";
import { byggDemoInstrument } from "@/felsokning/demo";
import { ECM_VERSION, UNDANTAGSORSAKER, grindGodkand, kvalitetsgrind } from "@/felsokning/ecm";
import {
ARENDETYPER,
ECM_VERSION,
MARKOR_FELBESKRIVNING_VERIFIERAD,
MARKOR_INGA_TIDIGA_OBSERVATIONER,
MARKOR_TIDIGA_OBSERVATIONER_KLARA,
UNDANTAGSORSAKER,
arendetyp,
grindGodkand,
kvalitetsgrind,
preDiagnostik,
sparbarhetspaket,
} from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -127,6 +140,10 @@ export default function ArendeSida() {
</div>
}
>
{/* Ärendeidentiteten: registreras en gång, alltid synlig — det ska
aldrig råda tvekan om vilket fordon och ärende som avses. */}
<IdentitetsRad arende={arende} skicka={skicka} />
{/* Klassisk trekolumnslayout på skrivbord: navigationsträd till
vänster, arbetsyta i mitten, kontextpanel till höger. På smala
skärmar: flikrad + en kolumn. */}
@@ -168,6 +185,238 @@ export default function ArendeSida() {
);
}
// Nyckel/värde-rader för identitetspanelerna (rapport + delade vyer).
function identitetsRader(rader: [string, string | undefined][]) {
return rader
.filter(([, varde]) => varde)
.map(([etikett, varde]) => (
<div key={etikett} className="flex justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span className="text-[#4A5560]">{etikett}</span>
<span className="text-right font-medium">{varde}</span>
</div>
));
}
// Ärendeidentiteten (Case Identity): kompakt rad med fordonsobjektet och
// ärendereferenserna — alltid synlig i arbetsytan. Ärendetypen väljs här
// och styr vilka dokumentationskrav ECM ställer.
function IdentitetsRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const idn = arendeidentitet(arende);
const typ = arendetyp(arende);
const del = (etikett: string, varde?: string) =>
varde ? (
<span className="whitespace-nowrap">
{etikett && <span className="text-[#707070]">{etikett} </span>}
<span className="font-semibold text-[#1A1A1A]">{varde}</span>
</span>
) : null;
return (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 text-[12px] print:hidden">
{del("AO", idn.arbetsorder)}
{del("Claim", idn.claim)}
{del("Skadenr", idn.skadenummer)}
{del("", idn.beskrivning)}
{del("Regnr", idn.identifierare)}
{del("VIN", idn.vin)}
{del("Miltal", idn.miltal)}
{del("Ansvarig", idn.ansvarig)}
<label className="ml-auto flex items-center gap-1">
<span className="text-[#707070]">Ärendetyp</span>
<select
value={typ}
disabled={idn.avslutat}
onChange={(e) => skicka({ typ: "arendetyp_satt", arendetyp: e.target.value })}
className="rounded border border-[#ADADAD] bg-white px-1.5 py-0.5 text-[12px] focus:border-[#00437A] focus:outline-none"
>
{ARENDETYPER.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
</div>
);
}
// Mätarställning in/ut: instrumentpanelen fotograferas, bildtolkningen
// föreslår värdet och teknikern bekräftar. Fotot blir den officiella
// mätarställningen (E2-evidens). Undantag kräver dokumenterad orsak.
function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; skicka: (h: Handelse) => void }) {
const filRef = useRef<HTMLInputElement>(null);
const [foto, setFoto] = useState<string | null>(null);
const [varde, setVarde] = useState("");
const [laser, setLaser] = useState(false);
const [demo, setDemo] = useState(false);
const fota = async (fil: File) => {
setLaser(true);
const dataUrl = await skalaNerFoto(fil);
setFoto(dataUrl);
try {
const res = await lasAvInstrument(dataUrl);
const km = res?.tolkning.varden.find((v) => /mätar|odo|km|mil/i.test(`${v.beskrivning} ${v.enhet ?? ""}`)) ?? res?.tolkning.varden[0];
if (km) setVarde(`${km.varde}${km.enhet ? ` ${km.enhet}` : ""}`);
if (!res) {
setDemo(true);
setVarde(lage === "ingaende" ? "84 320 km" : "84 512 km");
}
} catch {
// Avläsningen kunde inte nås — värdet anges manuellt.
}
setLaser(false);
};
return (
<div>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
if (fil) fota(fil);
e.target.value = "";
}}
/>
{!foto && !laser && (
<StorKnapp variant="sekundar" onClick={() => filRef.current?.click()}>
📷 Fotografera instrumentpanelen
</StorKnapp>
)}
{laser && <p className="animate-pulse py-1 text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av mätarställningen </p>}
{foto && !laser && (
<div className="mt-2">
{demo && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-1.5 text-[11px] font-semibold text-[#9A6700]">
Demo-avläsning bildtolkningen kräver inloggning. Kontrollera värdet.
</p>
)}
<img src={foto} alt="Instrumentpanel" className="mb-2 max-h-40 rounded border border-[#C6C6C6]" />
<TextFalt label={`Mätarställning (${lage === "ingaende" ? "ingående" : "utgående"})`} varde={varde} satt={setVarde} platshallare="T.ex. 84 320 km" />
<StorKnapp
disabled={!varde.trim()}
onClick={() => {
skicka({ typ: "matarstallning", lage, varde: varde.trim(), dataUrl: foto });
setFoto(null);
setVarde("");
}}
>
Spara mätarställning
</StorKnapp>
</div>
)}
<Undantag vidUndantag={(orsak) => skicka({ typ: "matarstallning", lage, varde: "", undantag: orsak })} />
</div>
);
}
// Pre-Diagnostic Validation: ingen felsökning påbörjas förrän
// grundkontrollerna är genomförda — eller dokumenterat motiverade.
function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const rader = preDiagnostik(arende);
const rad = (id: string) => rader.find((r) => r.id === id)!;
const [historikVal, setHistorikVal] = useState<"" | "ja" | "nej">("");
const [historikText, setHistorikText] = useState("");
const fb = felbeskrivning(arende);
const harTidiga = arende.handelser.some((p) => p.handelse.typ === "observation" || p.handelse.typ === "foto");
return (
<Panel rubrik="Pre-diagnostik — innan felsökningen börjar">
{rader.map((r) => (
<p key={r.id} className="py-0.5 text-[13px]">
<span className={r.klar ? "text-[#1E6B34]" : "text-[#8B1A1A]"}>{r.klar ? "✅" : "☐"}</span> {r.rubrik}
{r.varning && <span className="ml-1 text-[11px] font-semibold text-[#9A6700]"> {r.varning}</span>}
</p>
))}
{!rad("historik").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har fordonets historik kontrollerats? (tidigare arbeten, återkommande fel, TSB, kampanjer)
</p>
<div className="mb-2 grid grid-cols-2 gap-2">
<StorKnapp variant={historikVal === "ja" ? "primar" : "sekundar"} onClick={() => setHistorikVal("ja")}>
Ja kontrollerad
</StorKnapp>
<StorKnapp variant={historikVal === "nej" ? "fara" : "sekundar"} onClick={() => setHistorikVal("nej")}>
Nej
</StorKnapp>
</div>
{historikVal && (
<>
<TextFalt
label={historikVal === "ja" ? "Relevanta tidigare arbeten (valfritt — orsakskedja)" : "Orsak till att historiken inte kontrollerats (obligatoriskt)"}
varde={historikText}
satt={setHistorikText}
platshallare={historikVal === "ja" ? "T.ex. Vattenpump bytt för 8 mån sedan — misstänkt läckage samma område" : ""}
rost
/>
<StorKnapp
disabled={historikVal === "nej" && !historikText.trim()}
onClick={() => {
skicka({ typ: "historik_kontrollerad", kontrollerad: historikVal === "ja", kommentar: historikText.trim() || undefined });
setHistorikVal("");
setHistorikText("");
}}
>
Dokumentera
</StorKnapp>
</>
)}
</div>
)}
{!rad("matarstallning_in").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Ingående mätarställning fotografera instrumentpanelen
</p>
<MatarstallningSteg lage="ingaende" skicka={skicka} />
</div>
)}
{!rad("felbeskrivning").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Är kundens felbeskrivning korrekt återgiven?
</p>
{fb && <p className="mb-2 text-[13px]">{fb}</p>}
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_FELBESKRIVNING_VERIFIERAD}.` })}>
Stämmer verifierad
</StorKnapp>
<p className="mt-1 text-[11px] text-[#707070]">
Ytterligare symptom dokumenteras som separata observationer blanda dem inte med kundens beskrivning.
</p>
</div>
)}
{!rad("tidiga_observationer").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Något ytterligare vid mottagandet? (reparationsspår, modifieringar, skador, läckage, korrosion )
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_INGA_TIDIGA_OBSERVATIONER}.` })}>
Inga ytterligare observationer
</StorKnapp>
<StorKnapp
variant="sekundar"
disabled={!harTidiga}
onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_TIDIGA_OBSERVATIONER_KLARA}.` })}
>
Observationerna är dokumenterade
</StorKnapp>
</div>
<p className="mt-1 text-[11px] text-[#707070]">
Dokumentera med foto eller observation i panelen nedan knappen låses upp när något loggats.
</p>
</div>
)}
</Panel>
);
}
// Navigationsträd (vänsterkolumnen): ärendets vyer plus metodikens steg
// med status — som en mappstruktur i ett klassiskt verkstadssystem.
function VyTrad({
@@ -370,6 +619,18 @@ function GuideFlik({
);
}
// Pre-Diagnostic Validation: metodiken öppnas först när grund-
// kontrollerna är genomförda eller dokumenterat motiverade.
if (!preDiagnostik(arende).every((r) => r.klar)) {
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
<PreDiagnostikPanel arende={arende} skicka={skicka} />
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
</>
);
}
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
@@ -422,6 +683,14 @@ function GuideFlik({
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
{/* Utgående mätarställning: obligatorisk för kvalitetsgrinden när
ärendet avslutas — erbjuds så fort metodiken är genomarbetad. */}
{!arende.handelser.some((p) => p.handelse.typ === "matarstallning" && p.handelse.lage === "utgaende") && (
<Panel rubrik="Utgående mätarställning — inför avslut">
<MatarstallningSteg lage="utgaende" skicka={skicka} />
</Panel>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
Lämna över arbete
@@ -1174,6 +1443,17 @@ function RapportFlik({
const b = brief(arende, metodik, nu);
const bilder = foton(arende);
const fordelning = tidsfordelningsRader(arende, nu);
const idn = arendeidentitet(arende);
const typ = arendetyp(arende);
let matIn: string | undefined;
let matUt: string | undefined;
for (const p of arende.handelser) {
const h = p.handelse;
if (h.typ === "matarstallning" && !h.undantag) {
if (h.lage === "ingaende") matIn = h.varde;
else matUt = h.varde;
}
}
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
// och ingår inte i det som delas med kund.
const kundposter = arende.handelser.filter(
@@ -1181,11 +1461,13 @@ function RapportFlik({
);
// Alla exporter bygger på samma händelselogg och versionsmärks:
// version = antal händelser vid exporttillfället.
// version = antal händelser vid exporttillfället. Spårbarhetspaketet
// (ECM-version, evidensposter med hash, grindstatus) följer med.
const exporteraJson = () => {
const version = arende.handelser.length;
const data = {
export: { format: "JSON", version, exporteradAv: anvandare, tidpunkt: new Date().toISOString() },
ecm: sparbarhetspaket(arende, metodik),
arende,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
@@ -1262,13 +1544,30 @@ function RapportFlik({
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
</p>
</Panel>
<Panel rubrik={`Ärende #${arende.nummer}`}>
{b.objekt && (
<p className="text-[14px] font-semibold">
{b.objekt.beskrivning} · {b.objekt.identifierare}
</p>
)}
{b.felbeskrivning && <p className="mt-1 text-[14px]">Felbeskrivning: {b.felbeskrivning}</p>}
{/* Rapportens första sida: ärendeidentiteten — registrerad en gång,
återanvänd här automatiskt. */}
<Panel rubrik="Ärendeinformation">
{identitetsRader([
["Ärende", `#${arende.nummer}`],
["Arbetsorder", idn.arbetsorder],
["Claim-/garantinr", idn.claim],
["Skadenummer", idn.skadenummer],
["Ärendetyp", typ],
["Ansvarig tekniker", idn.ansvarig],
])}
</Panel>
<Panel rubrik="Fordonsinformation">
{identitetsRader([
["Fordon", idn.beskrivning],
["Regnr", idn.identifierare],
["VIN", idn.vin],
["Mätarställning in", matIn],
["Mätarställning ut", matUt],
["Kund", idn.kund],
])}
</Panel>
<Panel rubrik="Sammanfattning">
{b.felbeskrivning && <p className="text-[14px]">Felbeskrivning: {b.felbeskrivning}</p>}
<p className="mt-1 text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && (
<div className="mt-2">
+6
View File
@@ -83,6 +83,12 @@ export default function NyttArende() {
identifieringsmetod: regnr ? "Regnr" : vin ? "VIN" : "Manuell inmatning",
beskrivning: beskr || "Se arbetsorder",
kund: v("kund_namn"),
// Ärendeidentiteten: registreras en gång här, återanvänds i alla vyer.
vin: vin?.toUpperCase(),
miltal: v("fordon_matarstallning"),
arbetsorder: v("ao_nummer"),
claim: v("ao_claim"),
skadenummer: v("ao_skadenummer"),
};
const felText = v("felbeskrivning") ?? "Se skannad arbetsorder";
let metodikId: string | undefined;