diff --git a/docs/MVP.md b/docs/MVP.md
new file mode 100644
index 0000000..a973d86
--- /dev/null
+++ b/docs/MVP.md
@@ -0,0 +1,38 @@
+# Guidad Felsökning – MVP
+
+Första körbara versionen av kärnan i [Master Prompt v1.0](MASTER-PROMPT.md). Byggd som en fristående del av denna kodbas under `/felsokning`.
+
+## Kör
+
+```sh
+npm install
+npm run dev # öppna http://localhost:8080/felsokning
+npm test # projektionstester
+```
+
+## Vad som ingår
+
+| Direktivets kärna | Status i MVP |
+| --- | --- |
+| Objektidentifiering först | ✅ Manuell inmatning (reg.nr/VIN/serienummer/maskinnummer) med bekräftelsesteg. QR/streckkod/OCR är markerade som kommande. |
+| AI-guidad felsökning | ✅ Deterministisk metodikmotor: en fråga i taget, stora knappar. Två metodiker (vibration + generisk) som väljs utifrån felbeskrivningen. LLM-integrationen ansluter i samma motor senare. |
+| Arbetslogg | ✅ Append-only händelselogg med tidsstämpel och användare på varje post. Ingenting skrivs över. |
+| Tidredovisning | ✅ Kategorier (aktiv felsökning, väntetid, provkörning …) via kategoribyten i loggen; paus räknas inte i total tid. Inaktivitetsfråga efter 20 min utan händelser. |
+| Dokumentation | ✅ Observationer, mätvärden, foton (nedskalade), kommentarer och hypoteser. Hypoteser märks alltid 🔴 och kan aldrig loggas som konstaterade fel. |
+| Ärendebrief | ✅ Regenereras ur loggen vid varje visning: utförda kontroller, observationer, **ej kontrollerat**, rekommenderat nästa steg, tillförlitlighet, total arbetstid. |
+| Överlämning | ✅ ”Lämna över arbete” genererar överlämningsrapport ur briefen och loggar överlämningen. |
+| Kundrapport | ✅ Tidslinjevy utan interna poster, med bilder och tidsfördelning. Utskrift/PDF via webbläsaren, med påminnelse om granskning före delning. |
+| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON), men MVP:t lagrar lokalt (localStorage). Nästa steg: backend med samma händelsemodell. |
+
+## Arkitekturprinciper i koden
+
+- **Händelseloggen är enda sanningskällan.** `src/felsokning/domain.ts` definierar händelsetyperna; poster läggs endast till.
+- **Alla vyer är projektioner.** `src/felsokning/projektioner.ts` — brief, tidsfördelning, överlämningstext och kundrapport är rena funktioner av loggen och kan alltid regenereras. Testerna i `src/felsokning/__tests__/` låser detta.
+- **Metodikmotorn är deterministisk.** `src/felsokning/metodik.ts` — nästa steg härleds ur vad som redan dokumenterats. Det är här den framtida AI:n ansluter, utan att logg eller projektioner ändras.
+- **Fabriksverktygs-UI.** `src/felsokning/ui.tsx` — hög kontrast, minst 56 px höga knappar, få val per skärm.
+
+## Medvetna avgränsningar
+
+- Ingen backend/multi-tenant ännu — datat bor per enhet. Append-only-modellen är vald just för att synkronisering (inkl. offline) blir konfliktfri när backend läggs till.
+- Ingen LLM-koppling — guidningen är regelstyrd metodik. Gränssnittet mot motorn (`nastaSteg`) är den framtida integrationspunkten.
+- Röst, bildanalys och tillverkarintegrationer ingår inte i MVP:t.
diff --git a/src/App.tsx b/src/App.tsx
index 3912121..54a745b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -14,6 +14,9 @@ import Admin from "./pages/Admin";
import FAQ from "./pages/FAQ";
import OrderConfirmation from "./pages/OrderConfirmation";
import ChangeSubscription from "./pages/ChangeSubscription";
+import Arendelista from "./pages/felsokning/Arendelista";
+import NyttArende from "./pages/felsokning/NyttArende";
+import ArendeSida from "./pages/felsokning/ArendeSida";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
@@ -33,6 +36,9 @@ function AppContent() {
} />
} />
} />
+ } />
+ } />
+ } />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
} />
diff --git a/src/components/PaymentTestModeBanner.tsx b/src/components/PaymentTestModeBanner.tsx
index 7865139..81ce52d 100644
--- a/src/components/PaymentTestModeBanner.tsx
+++ b/src/components/PaymentTestModeBanner.tsx
@@ -1,6 +1,13 @@
+import { useLocation } from "react-router-dom";
+
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
export function PaymentTestModeBanner() {
+ const { pathname } = useLocation();
+ // Guidad Felsökning har inga betalningar — bannern gäller bara butiken.
+ if (pathname.startsWith("/felsokning")) {
+ return null;
+ }
if (!clientToken) {
return (
diff --git a/src/felsokning/__tests__/projektioner.test.ts b/src/felsokning/__tests__/projektioner.test.ts
new file mode 100644
index 0000000..6407eba
--- /dev/null
+++ b/src/felsokning/__tests__/projektioner.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from "vitest";
+import type { Arende, Handelse } from "../domain";
+import { nyLoggPost } from "../domain";
+import { VIBRATION_METODIK, nastaSteg, valjMetodik } from "../metodik";
+import { brief, ejKontrollerat, tidsfordelning } from "../projektioner";
+
+// Hjälpare: bygger ett ärende ur en lista (tidpunkt, händelse) —
+// precis som produktionskoden är alla vyer rena funktioner av loggen.
+function byggArende(poster: [string, Handelse][]): Arende {
+ return {
+ id: "test",
+ nummer: 1,
+ skapad: poster[0]?.[0] ?? "2026-08-02T08:00:00Z",
+ handelser: poster.map(([tidpunkt, handelse]) => nyLoggPost("Anna", handelse, tidpunkt)),
+ };
+}
+
+const OBJEKT: Handelse = {
+ typ: "objekt_identifierat",
+ objekt: {
+ typ: "Fordon",
+ identifierare: "ABC123",
+ identifieringsmetod: "Registreringsnummer",
+ beskrivning: "Volvo XC60 D4 2019",
+ kund: "Anders Svensson",
+ },
+};
+
+describe("metodikval", () => {
+ it("väljer vibrationsmetodiken när felbeskrivningen nämner vibration", () => {
+ expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
+ expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
+ });
+});
+
+describe("nastaSteg", () => {
+ it("börjar med första symptomfrågan och går vidare i metodikens ordning", () => {
+ const arende = byggArende([
+ ["2026-08-02T08:03:00Z", OBJEKT],
+ ["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
+ ]);
+ const steg = nastaSteg(arende, VIBRATION_METODIK);
+ expect(steg.klart).toBe(false);
+ expect(steg.steg.id).toBe("symptom");
+ expect(steg.fraga?.id).toBe("hastighetsberoende");
+ });
+
+ it("hoppar till nästa obesvarade fråga när en besvarats", () => {
+ const arende = byggArende([
+ ["2026-08-02T08:03:00Z", OBJEKT],
+ [
+ "2026-08-02T08:06:00Z",
+ { typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "…", svar: "Ja" },
+ ],
+ ]);
+ expect(nastaSteg(arende, VIBRATION_METODIK).fraga?.id).toBe("var");
+ });
+
+ it("är klart när alla frågor och kontroller är dokumenterade", () => {
+ const poster: [string, Handelse][] = [["2026-08-02T08:00:00Z", OBJEKT]];
+ for (const steg of VIBRATION_METODIK.steg) {
+ for (const fraga of steg.fragor ?? []) {
+ poster.push([
+ "2026-08-02T08:10:00Z",
+ { typ: "fraga_besvarad", stegId: steg.id, frageId: fraga.id, fraga: fraga.text, svar: "Ja" },
+ ]);
+ }
+ for (const kontroll of steg.kontroller ?? []) {
+ poster.push([
+ "2026-08-02T08:20:00Z",
+ { typ: "kontroll_utford", stegId: steg.id, kontrollId: kontroll.id, text: kontroll.text },
+ ]);
+ }
+ }
+ expect(nastaSteg(byggArende(poster), VIBRATION_METODIK).klart).toBe(true);
+ });
+});
+
+describe("ejKontrollerat", () => {
+ it("krymper när kontroller utförs och härleds helt ur loggen", () => {
+ const utan = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
+ const alla = ejKontrollerat(utan, VIBRATION_METODIK);
+ expect(alla).toContain("Kontrollera lufttryck");
+
+ const med = byggArende([
+ ["2026-08-02T08:00:00Z", OBJEKT],
+ [
+ "2026-08-02T08:15:00Z",
+ { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck" },
+ ],
+ ]);
+ const kvar = ejKontrollerat(med, VIBRATION_METODIK);
+ expect(kvar).not.toContain("Kontrollera lufttryck");
+ expect(kvar.length).toBe(alla.length - 1);
+ });
+});
+
+describe("tidsfordelning", () => {
+ it("attribuerar intervall till aktiv kategori och räknar inte paus i totalen", () => {
+ const arende = byggArende([
+ ["2026-08-02T08:00:00Z", OBJEKT],
+ // 30 min aktiv felsökning (standardkategori)
+ ["2026-08-02T08:30:00Z", { typ: "kategori_byte", kategori: "paus" }],
+ // 15 min paus
+ ["2026-08-02T08:45:00Z", { typ: "kategori_byte", kategori: "provkorning" }],
+ // 20 min provkörning
+ ["2026-08-02T09:05:00Z", { typ: "arende_avslutat" }],
+ ]);
+ const { totalMs, perKategori } = tidsfordelning(arende);
+ expect(perKategori.aktiv_felsokning).toBe(30 * 60000);
+ expect(perKategori.paus).toBe(15 * 60000);
+ expect(perKategori.provkorning).toBe(20 * 60000);
+ expect(totalMs).toBe(50 * 60000);
+ });
+
+ it("räknar pågående tid fram till 'nu' för öppna ärenden", () => {
+ const arende = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
+ const { totalMs } = tidsfordelning(arende, "2026-08-02T08:10:00Z");
+ expect(totalMs).toBe(10 * 60000);
+ });
+});
+
+describe("brief", () => {
+ const arende = byggArende([
+ ["2026-08-02T08:03:00Z", OBJEKT],
+ ["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
+ [
+ "2026-08-02T08:11:00Z",
+ { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "2,4 bar" },
+ ],
+ ["2026-08-02T08:18:00Z", { typ: "matvarde", beskrivning: "Matningsspänning", varde: "13,9", enhet: "V" }],
+ ["2026-08-02T08:20:00Z", { typ: "observation", text: "Höger framdäck visar ojämnt slitage" }],
+ ["2026-08-02T08:22:00Z", { typ: "hypotes", text: "Obalans i höger framhjul", niva: "lag" }],
+ ]);
+
+ it("sammanställer utfört, observerat, ej kontrollerat och nästa steg ur loggen", () => {
+ const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
+ expect(b.objekt?.identifierare).toBe("ABC123");
+ expect(b.felbeskrivning).toBe("Bilen vibrerar runt 88 km/h");
+ expect(b.utfordaKontroller.map((k) => k.text)).toContain("Kontrollera lufttryck");
+ expect(b.observationer).toContain("Höger framdäck visar ojämnt slitage");
+ expect(b.observationer).toContain("Matningsspänning: 13,9 V");
+ expect(b.ejKontrollerat).toContain("Kontrollera hjulbalansering");
+ expect(b.ejKontrollerat).not.toContain("Kontrollera lufttryck");
+ expect(b.rekommenderatNastaSteg[0]).toContain("hastighetsberoende");
+ expect(b.totalArbetstid).toBe("27 min");
+ });
+
+ it("skiljer hypoteser från fakta och markerar felorsak som ej verifierad", () => {
+ const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
+ expect(b.hypoteser).toEqual([{ text: "Obalans i höger framhjul", niva: "lag" }]);
+ expect(b.observationer).not.toContain("Obalans i höger framhjul");
+ expect(b.tillforlitlighet.some((r) => r.niva === "medel" && /inte verifierad/.test(r.text))).toBe(true);
+ });
+
+ it("är regenererbar: samma logg ger alltid samma brief", () => {
+ const b1 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
+ const b2 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
+ expect(b2).toEqual(b1);
+ });
+});
diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts
new file mode 100644
index 0000000..9830ef0
--- /dev/null
+++ b/src/felsokning/domain.ts
@@ -0,0 +1,112 @@
+// Domänmodell för Guidad Felsökning.
+// Händelseloggen är append-only och är ärendets enda sanningskälla.
+// Allt annat (brief, tidrapport, kundrapport) är projektioner av loggen.
+
+export type Tillforlitlighet = "hog" | "medel" | "lag";
+
+export const TILLFORLITLIGHET_LABEL: Record = {
+ hog: "🟢 Hög",
+ medel: "🟡 Medel",
+ lag: "🔴 Låg",
+};
+
+export type TidKategori =
+ | "aktiv_felsokning"
+ | "vantetid"
+ | "administration"
+ | "reservdelssokning"
+ | "provkorning"
+ | "kundkontakt"
+ | "paus";
+
+export const TIDKATEGORI_LABEL: Record = {
+ aktiv_felsokning: "Aktiv felsökning",
+ vantetid: "Väntetid",
+ administration: "Administration",
+ reservdelssokning: "Reservdelssökning",
+ provkorning: "Provkörning",
+ kundkontakt: "Kundkontakt",
+ paus: "Paus",
+};
+
+export interface Objekt {
+ typ: string; // t.ex. "Fordon", "Industrimaskin"
+ identifierare: string; // reg.nr, VIN, serienummer …
+ identifieringsmetod: string; // "Registreringsnummer", "VIN", "Serienummer", "Manuell inmatning"
+ beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
+ kund?: string;
+}
+
+// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
+export type Handelse =
+ | { typ: "objekt_identifierat"; objekt: Objekt }
+ | { typ: "felbeskrivning"; text: string }
+ | { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
+ | { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string }
+ | { typ: "observation"; text: string }
+ | { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
+ | { typ: "hypotes"; text: string; niva: Exclude }
+ | { typ: "foto"; beskrivning: string; dataUrl: string }
+ | { typ: "kommentar"; text: string }
+ | { typ: "kategori_byte"; kategori: TidKategori }
+ | { typ: "inaktivitet_forklarad"; text: string; minuter: number }
+ | { typ: "overlamning"; fran: string; till?: string }
+ | { typ: "arende_avslutat" };
+
+export interface LoggPost {
+ id: string;
+ tidpunkt: string; // ISO 8601
+ anvandare: string;
+ handelse: Handelse;
+}
+
+export interface Arende {
+ id: string;
+ nummer: number;
+ skapad: string; // ISO 8601
+ handelser: LoggPost[];
+}
+
+let raknare = 0;
+
+export function nyLoggPost(anvandare: string, handelse: Handelse, tidpunkt?: string): LoggPost {
+ raknare += 1;
+ return {
+ id: `${Date.now().toString(36)}-${raknare.toString(36)}`,
+ tidpunkt: tidpunkt ?? new Date().toISOString(),
+ anvandare,
+ handelse,
+ };
+}
+
+export function handelseRubrik(post: LoggPost): string {
+ const h = post.handelse;
+ switch (h.typ) {
+ case "objekt_identifierat":
+ return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`;
+ case "felbeskrivning":
+ return `Felbeskrivning registrerad`;
+ case "fraga_besvarad":
+ return `${h.fraga} — ${h.svar}`;
+ case "kontroll_utford":
+ return h.resultat ? `${h.text} — ${h.resultat}` : `${h.text} — utförd`;
+ case "observation":
+ return `Observation: ${h.text}`;
+ case "matvarde":
+ return `Mätvärde: ${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`;
+ case "hypotes":
+ return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`;
+ case "foto":
+ return `Foto: ${h.beskrivning}`;
+ case "kommentar":
+ return h.text;
+ case "kategori_byte":
+ return `Arbetskategori: ${TIDKATEGORI_LABEL[h.kategori]}`;
+ case "inaktivitet_forklarad":
+ return `Komplettering (${h.minuter} min utan aktivitet): ${h.text}`;
+ case "overlamning":
+ return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
+ case "arende_avslutat":
+ return "Felsökning avslutad";
+ }
+}
diff --git a/src/felsokning/format.ts b/src/felsokning/format.ts
new file mode 100644
index 0000000..2fbca62
--- /dev/null
+++ b/src/felsokning/format.ts
@@ -0,0 +1,31 @@
+// Formatering och bildhantering — utan React-beroenden.
+
+export function tidKlockslag(iso: string): string {
+ return new Date(iso).toLocaleTimeString("sv-SE", { hour: "2-digit", minute: "2-digit" });
+}
+
+export function tidDatum(iso: string): string {
+ return new Date(iso).toLocaleDateString("sv-SE");
+}
+
+// Skalar ner ett foto innan det loggas, så att händelseloggen förblir hanterbar.
+export function skalaNerFoto(fil: File, maxSida = 1024): Promise {
+ return new Promise((resolve, reject) => {
+ const lasare = new FileReader();
+ lasare.onerror = () => reject(new Error("Kunde inte läsa filen"));
+ lasare.onload = () => {
+ const bild = new Image();
+ bild.onerror = () => reject(new Error("Kunde inte tolka bilden"));
+ bild.onload = () => {
+ const skala = Math.min(1, maxSida / Math.max(bild.width, bild.height));
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.round(bild.width * skala);
+ canvas.height = Math.round(bild.height * skala);
+ canvas.getContext("2d")!.drawImage(bild, 0, 0, canvas.width, canvas.height);
+ resolve(canvas.toDataURL("image/jpeg", 0.7));
+ };
+ bild.src = lasare.result as string;
+ };
+ lasare.readAsDataURL(fil);
+ });
+}
diff --git a/src/felsokning/metodik.ts b/src/felsokning/metodik.ts
new file mode 100644
index 0000000..03ae586
--- /dev/null
+++ b/src/felsokning/metodik.ts
@@ -0,0 +1,190 @@
+// Metodikmotor: deterministiska felsökningsflöden.
+// Systemet presenterar aldrig en hypotes som ett konstaterat fel —
+// motorn föreslår nästa verifierbara steg utifrån vad som redan är dokumenterat.
+//
+// I MVP:t är metodikerna fördefinierade flöden. Den framtida AI-integrationen
+// ansluter här: den väljer/genererar steg, men händelseloggen och
+// projektionerna är oförändrade.
+
+import type { Arende } from "./domain";
+
+export interface Fraga {
+ id: string;
+ text: string;
+ svarstyp: "janej" | "text" | "val";
+ val?: string[];
+}
+
+export interface Kontroll {
+ id: string;
+ text: string;
+}
+
+export interface MetodikSteg {
+ id: string;
+ rubrik: string;
+ beskrivning?: string;
+ fragor?: Fraga[];
+ kontroller?: Kontroll[];
+}
+
+export interface Metodik {
+ id: string;
+ namn: string;
+ steg: MetodikSteg[];
+}
+
+export const VIBRATION_METODIK: Metodik = {
+ id: "vibration",
+ namn: "Vibration under körning",
+ steg: [
+ {
+ id: "symptom",
+ rubrik: "Verifiera symptom",
+ fragor: [
+ { id: "hastighetsberoende", text: "Är vibrationen hastighetsberoende?", svarstyp: "janej" },
+ {
+ id: "var",
+ text: "Var känns vibrationen?",
+ svarstyp: "val",
+ val: ["I ratten", "I stolen", "I hela bilen"],
+ },
+ {
+ id: "nar",
+ text: "När uppstår vibrationen?",
+ svarstyp: "val",
+ val: ["Vid acceleration", "Vid jämn fart", "Vid inbromsning", "Alltid under körning"],
+ },
+ {
+ id: "intervall",
+ text: "Försvinner den över eller under ett visst hastighetsintervall?",
+ svarstyp: "text",
+ },
+ ],
+ },
+ {
+ id: "visuell",
+ rubrik: "Visuell kontroll",
+ beskrivning: "Fotografera samtliga hjul. Skilj på observation och slutsats.",
+ kontroller: [
+ { id: "foto_vf", text: "Fotografera vänster framhjul" },
+ { id: "foto_hf", text: "Fotografera höger framhjul" },
+ { id: "foto_vb", text: "Fotografera vänster bakhjul" },
+ { id: "foto_hb", text: "Fotografera höger bakhjul" },
+ { id: "dot", text: "Dokumentera DOT-/tillverkningsdatum" },
+ { id: "slitage", text: "Kontrollera däckslitage" },
+ ],
+ },
+ {
+ id: "kontroller",
+ rubrik: "Rekommenderade kontroller",
+ kontroller: [
+ { id: "lufttryck", text: "Kontrollera lufttryck" },
+ { id: "hjulmoment", text: "Kontrollera hjulmoment" },
+ { id: "kast", text: "Kontrollera radial- och sidokast" },
+ { id: "balansering", text: "Kontrollera hjulbalansering" },
+ { id: "bussningar", text: "Kontrollera bussningar och leder" },
+ ],
+ },
+ {
+ id: "provkorning",
+ rubrik: "Provkörning",
+ beskrivning: "Verifiera under provkörning:",
+ kontroller: [
+ { id: "pk_hastighet", text: "Hastighet där vibration uppstår" },
+ { id: "pk_acc", text: "Förändring vid acceleration" },
+ { id: "pk_motorbroms", text: "Förändring vid motorbroms" },
+ { id: "pk_kurva", text: "Förändring vid kurvtagning" },
+ { id: "pk_var", text: "Om vibration känns i ratt eller kaross" },
+ ],
+ },
+ ],
+};
+
+export const GENERISK_METODIK: Metodik = {
+ id: "generisk",
+ namn: "Generell strukturerad felsökning",
+ steg: [
+ {
+ id: "symptom",
+ rubrik: "Verifiera symptom",
+ fragor: [
+ { id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
+ {
+ id: "nar",
+ text: "När uppträder felet?",
+ svarstyp: "val",
+ val: ["Alltid", "Ibland", "Endast under drift", "Endast vid start"],
+ },
+ { id: "forlopp", text: "Beskriv förloppet när felet uppstår", svarstyp: "text" },
+ { id: "forandring", text: "Har något förändrats nyligen (service, reparation, miljö)?", svarstyp: "text" },
+ ],
+ },
+ {
+ id: "visuell",
+ rubrik: "Visuell kontroll",
+ kontroller: [
+ { id: "foto_objekt", text: "Fotografera objektet och det berörda området" },
+ { id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar" },
+ { id: "typskylt", text: "Dokumentera typskylt/märkning" },
+ ],
+ },
+ {
+ id: "grundkontroller",
+ rubrik: "Grundkontroller",
+ kontroller: [
+ { id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)" },
+ { id: "sakringar", text: "Kontrollera säkringar/skydd" },
+ { id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar" },
+ ],
+ },
+ {
+ id: "funktionstest",
+ rubrik: "Funktionstest",
+ kontroller: [
+ { id: "test", text: "Genomför funktionstest och dokumentera resultatet" },
+ ],
+ },
+ ],
+};
+
+const METODIKER = [VIBRATION_METODIK, GENERISK_METODIK];
+
+export function metodikForId(id: string): Metodik {
+ return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
+}
+
+export function valjMetodik(felbeskrivning: string): Metodik {
+ const text = felbeskrivning.toLowerCase();
+ if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
+ return GENERISK_METODIK;
+}
+
+export interface NastaSteg {
+ steg: MetodikSteg;
+ fraga?: Fraga;
+ kontroll?: Kontroll;
+ klart: boolean;
+}
+
+// Nästa steg = första obesvarade frågan, därefter första ej utförda kontrollen,
+// i metodikens ordning. Helt härlett ur händelseloggen.
+export function nastaSteg(arende: Arende, metodik: Metodik): NastaSteg {
+ const besvarade = new Set();
+ const utforda = new Set();
+ for (const post of arende.handelser) {
+ const h = post.handelse;
+ if (h.typ === "fraga_besvarad") besvarade.add(`${h.stegId}/${h.frageId}`);
+ if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
+ }
+ for (const steg of metodik.steg) {
+ for (const fraga of steg.fragor ?? []) {
+ if (!besvarade.has(`${steg.id}/${fraga.id}`)) return { steg, fraga, klart: false };
+ }
+ for (const kontroll of steg.kontroller ?? []) {
+ if (!utforda.has(`${steg.id}/${kontroll.id}`)) return { steg, kontroll, klart: false };
+ }
+ }
+ const sista = metodik.steg[metodik.steg.length - 1];
+ return { steg: sista, klart: true };
+}
diff --git a/src/felsokning/projektioner.ts b/src/felsokning/projektioner.ts
new file mode 100644
index 0000000..1533443
--- /dev/null
+++ b/src/felsokning/projektioner.ts
@@ -0,0 +1,247 @@
+// Projektioner: alla vyer (ärendebrief, tidrapport, kundrapport) är rena
+// funktioner av händelseloggen. De kan alltid regenereras — loggen vinner
+// om en vy och loggen skulle säga olika saker.
+
+import type { Arende, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
+import { TIDKATEGORI_LABEL } from "./domain";
+import type { Metodik } from "./metodik";
+import { nastaSteg } from "./metodik";
+
+export function objekt(arende: Arende): Objekt | undefined {
+ for (const post of arende.handelser) {
+ if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt;
+ }
+ return undefined;
+}
+
+export function felbeskrivning(arende: Arende): string | undefined {
+ for (const post of arende.handelser) {
+ if (post.handelse.typ === "felbeskrivning") return post.handelse.text;
+ }
+ return undefined;
+}
+
+export function arAvslutat(arende: Arende): boolean {
+ return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat");
+}
+
+export interface UtfordKontroll {
+ text: string;
+ resultat?: string;
+ tidpunkt: string;
+ anvandare: string;
+}
+
+export function utfordaKontroller(arende: Arende): UtfordKontroll[] {
+ const resultat: UtfordKontroll[] = [];
+ for (const post of arende.handelser) {
+ const h = post.handelse;
+ if (h.typ === "kontroll_utford") {
+ resultat.push({ text: h.text, resultat: h.resultat, tidpunkt: post.tidpunkt, anvandare: post.anvandare });
+ }
+ }
+ return resultat;
+}
+
+export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] {
+ const utforda = new Set();
+ for (const post of arende.handelser) {
+ const h = post.handelse;
+ if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
+ }
+ const kvar: string[] = [];
+ for (const steg of metodik.steg) {
+ for (const kontroll of steg.kontroller ?? []) {
+ if (!utforda.has(`${steg.id}/${kontroll.id}`)) kvar.push(kontroll.text);
+ }
+ }
+ return kvar;
+}
+
+export function observationer(arende: Arende): string[] {
+ const resultat: string[] = [];
+ for (const post of arende.handelser) {
+ const h = post.handelse;
+ if (h.typ === "observation") resultat.push(h.text);
+ if (h.typ === "matvarde") resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
+ }
+ return resultat;
+}
+
+export function hypoteser(arende: Arende): { text: string; niva: Tillforlitlighet }[] {
+ const resultat: { text: string; niva: Tillforlitlighet }[] = [];
+ for (const post of arende.handelser) {
+ if (post.handelse.typ === "hypotes") resultat.push({ text: post.handelse.text, niva: post.handelse.niva });
+ }
+ return resultat;
+}
+
+export function foton(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
+ const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
+ for (const post of arende.handelser) {
+ if (post.handelse.typ === "foto") {
+ resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
+ }
+ }
+ return resultat;
+}
+
+// Tidsredovisning: varje intervall mellan två händelser tillhör den kategori
+// som var aktiv när intervallet började. Kategori styrs av kategori_byte-
+// händelser; standard är aktiv felsökning. Paus räknas inte in i total tid.
+export interface Tidsfordelning {
+ totalMs: number;
+ perKategori: Partial>;
+}
+
+export function tidsfordelning(arende: Arende, nu?: string): Tidsfordelning {
+ const poster = arende.handelser;
+ const perKategori: Partial> = {};
+ if (poster.length === 0) return { totalMs: 0, perKategori };
+
+ let kategori: TidKategori = "aktiv_felsokning";
+ let forra = new Date(poster[0].tidpunkt).getTime();
+ let avslutad = false;
+
+ const laggTill = (till: number) => {
+ const ms = Math.max(0, till - forra);
+ perKategori[kategori] = (perKategori[kategori] ?? 0) + ms;
+ forra = till;
+ };
+
+ for (const post of poster) {
+ laggTill(new Date(post.tidpunkt).getTime());
+ const h = post.handelse;
+ if (h.typ === "kategori_byte") kategori = h.kategori;
+ if (h.typ === "arende_avslutat") {
+ avslutad = true;
+ break;
+ }
+ }
+ if (!avslutad && nu) laggTill(new Date(nu).getTime());
+
+ let totalMs = 0;
+ for (const [k, ms] of Object.entries(perKategori)) {
+ if (k !== "paus") totalMs += ms;
+ }
+ return { totalMs, perKategori };
+}
+
+export function formateraTid(ms: number): string {
+ const minuter = Math.round(ms / 60000);
+ const h = Math.floor(minuter / 60);
+ const m = minuter % 60;
+ if (h === 0) return `${m} min`;
+ return `${h} tim ${m} min`;
+}
+
+export interface TillforlitlighetsRad {
+ niva: Tillforlitlighet;
+ text: string;
+}
+
+export function tillforlitlighet(arende: Arende): TillforlitlighetsRad[] {
+ const rader: TillforlitlighetsRad[] = [];
+ if (objekt(arende)) rader.push({ niva: "hog", text: "Objekt identifierat" });
+ if (foton(arende).length > 0) rader.push({ niva: "hog", text: "Bilder dokumenterade" });
+ if (arende.handelser.some((p) => p.handelse.typ === "matvarde")) {
+ rader.push({ niva: "hog", text: "Mätvärden registrerade" });
+ }
+ if (utfordaKontroller(arende).length > 0) {
+ rader.push({ niva: "hog", text: "Kontroller dokumenterade" });
+ }
+ // Felorsaken är per definition inte verifierad förrän ärendet avslutats
+ // med en verifierad slutsats — MVP:t markerar den alltid som ej verifierad
+ // så länge ärendet är öppet.
+ if (!arAvslutat(arende)) {
+ rader.push({ niva: "medel", text: "Felorsak ännu inte verifierad" });
+ }
+ return rader;
+}
+
+export interface Brief {
+ objekt?: Objekt;
+ felbeskrivning?: string;
+ utfordaKontroller: UtfordKontroll[];
+ observationer: string[];
+ hypoteser: { text: string; niva: Tillforlitlighet }[];
+ ejKontrollerat: string[];
+ rekommenderatNastaSteg: string[];
+ totalArbetstid: string;
+ tillforlitlighet: TillforlitlighetsRad[];
+ avslutat: boolean;
+}
+
+// Ärendebriefen: hela den strukturerade sammanfattningen, regenererbar
+// ur loggen vid varje anrop.
+export function brief(arende: Arende, metodik: Metodik, nu?: string): Brief {
+ const kvar = ejKontrollerat(arende, metodik);
+ const nasta = nastaSteg(arende, metodik);
+ const rekommenderat: string[] = [];
+ if (!arAvslutat(arende)) {
+ if (nasta.fraga) rekommenderat.push(`Besvara: ${nasta.fraga.text}`);
+ else if (nasta.kontroll) rekommenderat.push(nasta.kontroll.text);
+ for (const text of kvar.slice(0, 3)) {
+ if (!rekommenderat.includes(text)) rekommenderat.push(text);
+ }
+ if (rekommenderat.length === 0) {
+ rekommenderat.push("Samtliga kontroller i metodiken utförda. Verifiera slutsats eller utöka felsökningen.");
+ }
+ }
+ return {
+ objekt: objekt(arende),
+ felbeskrivning: felbeskrivning(arende),
+ utfordaKontroller: utfordaKontroller(arende),
+ observationer: observationer(arende),
+ hypoteser: hypoteser(arende),
+ ejKontrollerat: kvar,
+ rekommenderatNastaSteg: rekommenderat.slice(0, 3),
+ totalArbetstid: formateraTid(tidsfordelning(arende, nu).totalMs),
+ tillforlitlighet: tillforlitlighet(arende),
+ avslutat: arAvslutat(arende),
+ };
+}
+
+// Överlämningsrapport som text — används vid "Lämna över arbete".
+export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string): string {
+ const b = brief(arende, metodik, nu);
+ const rader: string[] = [];
+ rader.push(`ÖVERLÄMNING – Ärende #${arende.nummer}`);
+ if (b.objekt) rader.push(`Objekt: ${b.objekt.beskrivning} (${b.objekt.identifierare})${b.objekt.kund ? ` – Kund: ${b.objekt.kund}` : ""}`);
+ if (b.felbeskrivning) rader.push(`Felbeskrivning: ${b.felbeskrivning}`);
+ rader.push("");
+ rader.push("Utförda kontroller:");
+ if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)");
+ for (const k of b.utfordaKontroller) rader.push(` ✓ ${k.text}${k.resultat ? ` — ${k.resultat}` : ""}`);
+ rader.push("");
+ rader.push("Observationer:");
+ if (b.observationer.length === 0) rader.push(" (inga ännu)");
+ for (const o of b.observationer) rader.push(` • ${o}`);
+ if (b.hypoteser.length > 0) {
+ rader.push("");
+ rader.push("Hypoteser (ej verifierade):");
+ for (const h of b.hypoteser) rader.push(` ? ${h.text}`);
+ }
+ rader.push("");
+ rader.push("Ej kontrollerat:");
+ if (b.ejKontrollerat.length === 0) rader.push(" (allt i metodiken utfört)");
+ for (const e of b.ejKontrollerat) rader.push(` – ${e}`);
+ rader.push("");
+ rader.push("Rekommenderat nästa steg:");
+ b.rekommenderatNastaSteg.forEach((s, i) => rader.push(` ${i + 1}. ${s}`));
+ rader.push("");
+ rader.push(`Total arbetstid: ${b.totalArbetstid}`);
+ return rader.join("\n");
+}
+
+export function tidsfordelningsRader(arende: Arende, nu?: string): { label: string; tid: string }[] {
+ const { perKategori } = tidsfordelning(arende, nu);
+ return (Object.entries(perKategori) as [TidKategori, number][])
+ .filter(([, ms]) => ms >= 30000)
+ .sort((a, b) => b[1] - a[1])
+ .map(([k, ms]) => ({ label: TIDKATEGORI_LABEL[k], tid: formateraTid(ms) }));
+}
+
+export function sistaAktivitet(arende: Arende): LoggPost | undefined {
+ return arende.handelser[arende.handelser.length - 1];
+}
diff --git a/src/felsokning/store.ts b/src/felsokning/store.ts
new file mode 100644
index 0000000..38ae731
--- /dev/null
+++ b/src/felsokning/store.ts
@@ -0,0 +1,75 @@
+// Tillstånd för Guidad Felsökning (MVP).
+// Persistens: localStorage. I produktionsversionen ersätts detta av ett
+// API-first-backend med samma händelsemodell — append-only-loggen gör
+// synkronisering (inkl. offline-läge) konfliktfri.
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { Arende, Handelse, Objekt } from "./domain";
+import { nyLoggPost } from "./domain";
+import { felbeskrivning } from "./projektioner";
+import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
+
+interface FelsokningState {
+ anvandare: string;
+ arenden: Record;
+ nastaNummer: number;
+ sattAnvandare: (namn: string) => void;
+ skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
+ laggTill: (arendeId: string, handelse: Handelse) => void;
+}
+
+export const useFelsokning = create()(
+ persist(
+ (set, get) => ({
+ anvandare: "",
+ arenden: {},
+ nastaNummer: 1,
+
+ sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
+
+ skapaArende: (objekt, felbeskrivningText) => {
+ const { anvandare, nastaNummer } = get();
+ const id = `arende-${Date.now().toString(36)}`;
+ const arende: Arende = {
+ id,
+ nummer: nastaNummer,
+ skapad: new Date().toISOString(),
+ handelser: [
+ nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
+ nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
+ ],
+ };
+ set((s) => ({
+ arenden: { ...s.arenden, [id]: arende },
+ nastaNummer: s.nastaNummer + 1,
+ }));
+ return id;
+ },
+
+ laggTill: (arendeId, handelse) => {
+ const { anvandare } = get();
+ set((s) => {
+ const arende = s.arenden[arendeId];
+ if (!arende) return s;
+ return {
+ arenden: {
+ ...s.arenden,
+ [arendeId]: {
+ ...arende,
+ handelser: [...arende.handelser, nyLoggPost(anvandare, handelse)],
+ },
+ },
+ };
+ });
+ },
+ }),
+ { name: "guidad-felsokning" },
+ ),
+);
+
+export function metodikForArende(arende: Arende | undefined): Metodik {
+ if (!arende) return GENERISK_METODIK;
+ const text = felbeskrivning(arende);
+ return text ? valjMetodik(text) : GENERISK_METODIK;
+}
diff --git a/src/felsokning/ui.tsx b/src/felsokning/ui.tsx
new file mode 100644
index 0000000..05d218b
--- /dev/null
+++ b/src/felsokning/ui.tsx
@@ -0,0 +1,112 @@
+// Delade UI-komponenter för Guidad Felsökning.
+// Designfilosofi: professionellt fabriksverktyg — hög kontrast, stora
+// träffytor (handskvänligt), få val per skärm, inga distraktioner.
+
+import { Link } from "react-router-dom";
+import type { ReactNode } from "react";
+import type { Tillforlitlighet } from "./domain";
+import { TILLFORLITLIGHET_LABEL } from "./domain";
+
+export function FelsokningSkal({
+ rubrik,
+ tillbaka,
+ hoger,
+ children,
+}: {
+ rubrik: string;
+ tillbaka?: { till: string; text: string };
+ hoger?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+