Bygg MVP av Guidad Felsökning under /felsokning
Körbar kärna enligt Master Prompt v1.0: - Append-only händelselogg som enda sanningskälla (domain.ts); brief, tidsfördelning, överlämningstext och kundrapport är rena projektioner av loggen (projektioner.ts) med tester som låser att briefen alltid kan regenereras. - Deterministisk metodikmotor (metodik.ts): en fråga i taget, vibrationsmetodik + generisk metodik vald utifrån felbeskrivningen. Framtida AI-integration ansluter i nastaSteg utan modelländringar. - Flöde: identifiera objekt → bekräfta → felbeskrivning → guidad felsökning med foto, mätvärden, observationer och hypoteser (hypoteser märks alltid som ej verifierade). - Tidredovisning med kategorier, inaktivitetsfråga efter 20 min, överlämning med ett klick, kundrapport med tidslinje och utskrift. - Fabriksverktygs-UI: hög kontrast, stora knappar, få val per skärm. - Lagring i localStorage i MVP:t; docs/MVP.md beskriver omfattning och medvetna avgränsningar. Verifierat: 11 vitest-tester gröna, produktionsbygge ok, Playwright- röktest genom hela flödet utan sidfel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Tillforlitlighet, string> = {
|
||||
hog: "🟢 Hög",
|
||||
medel: "🟡 Medel",
|
||||
lag: "🔴 Låg",
|
||||
};
|
||||
|
||||
export type TidKategori =
|
||||
| "aktiv_felsokning"
|
||||
| "vantetid"
|
||||
| "administration"
|
||||
| "reservdelssokning"
|
||||
| "provkorning"
|
||||
| "kundkontakt"
|
||||
| "paus";
|
||||
|
||||
export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
|
||||
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<Tillforlitlighet, "hog"> }
|
||||
| { 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";
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<string>();
|
||||
const utforda = new Set<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<Record<TidKategori, number>>;
|
||||
}
|
||||
|
||||
export function tidsfordelning(arende: Arende, nu?: string): Tidsfordelning {
|
||||
const poster = arende.handelser;
|
||||
const perKategori: Partial<Record<TidKategori, number>> = {};
|
||||
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];
|
||||
}
|
||||
@@ -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<string, Arende>;
|
||||
nastaNummer: number;
|
||||
sattAnvandare: (namn: string) => void;
|
||||
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
|
||||
laggTill: (arendeId: string, handelse: Handelse) => void;
|
||||
}
|
||||
|
||||
export const useFelsokning = create<FelsokningState>()(
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-50">
|
||||
<header className="sticky top-0 z-20 border-b-2 border-zinc-700 bg-zinc-900 px-4 py-3">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{tillbaka && (
|
||||
<Link to={tillbaka.till} className="text-sm font-semibold text-amber-400 hover:underline">
|
||||
← {tillbaka.text}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="truncate text-xl font-extrabold tracking-tight">{rubrik}</h1>
|
||||
</div>
|
||||
{hoger}
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-3xl px-4 py-5 pb-24">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`min-h-14 w-full rounded-lg px-5 py-3 text-lg font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${farg} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4">
|
||||
{rubrik && <h2 className="mb-3 text-sm font-extrabold uppercase tracking-widest text-zinc-400">{rubrik}</h2>}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function NivaBadge({ niva }: { niva: Tillforlitlighet }) {
|
||||
return <span className="whitespace-nowrap text-sm font-bold">{TILLFORLITLIGHET_LABEL[niva]}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
|
||||
{flerRad ? (
|
||||
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
|
||||
) : (
|
||||
<input value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} className={klass} />
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user