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 ( +
+
+
+
+ {tillbaka && ( + + ← {tillbaka.text} + + )} +

{rubrik}

+
+ {hoger} +
+
+
{children}
+
+ ); +} + +export function StorKnapp({ + children, + onClick, + variant = "primar", + disabled, + type = "button", + className = "", +}: { + children: ReactNode; + onClick?: () => void; + variant?: "primar" | "sekundar" | "fara"; + disabled?: boolean; + type?: "button" | "submit"; + className?: string; +}) { + const farg = + variant === "primar" + ? "bg-amber-400 text-zinc-950 hover:bg-amber-300 active:bg-amber-500" + : variant === "fara" + ? "bg-red-600 text-white hover:bg-red-500 active:bg-red-700" + : "bg-zinc-800 text-zinc-50 border-2 border-zinc-600 hover:bg-zinc-700 active:bg-zinc-600"; + return ( + + ); +} + +export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNode }) { + return ( +
+ {rubrik &&

{rubrik}

} + {children} +
+ ); +} + +export function NivaBadge({ niva }: { niva: Tillforlitlighet }) { + return {TILLFORLITLIGHET_LABEL[niva]}; +} + +export function TextFalt({ + label, + varde, + satt, + platshallare, + flerRad, +}: { + label: string; + varde: string; + satt: (v: string) => void; + platshallare?: string; + flerRad?: boolean; +}) { + const klass = + "w-full rounded-lg border-2 border-zinc-600 bg-zinc-950 px-4 py-3 text-lg text-zinc-50 placeholder-zinc-500 focus:border-amber-400 focus:outline-none"; + return ( +