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:
Claude
2026-08-02 22:34:29 +00:00
parent e519d1f80d
commit 92fa8d6716
13 changed files with 1772 additions and 0 deletions
+38
View File
@@ -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.
+6
View File
@@ -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() {
<Route path="/admin" element={<Admin />} />
<Route path="/order-confirmation" element={<OrderConfirmation />} />
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
<Route path="/felsokning" element={<Arendelista />} />
<Route path="/felsokning/nytt" element={<NyttArende />} />
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
+7
View File
@@ -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 (
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
@@ -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);
});
});
+112
View File
@@ -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";
}
}
+31
View File
@@ -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);
});
}
+190
View File
@@ -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 };
}
+247
View File
@@ -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];
}
+75
View File
@@ -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;
}
+112
View File
@@ -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>
);
}
+589
View File
@@ -0,0 +1,589 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Arende, Handelse, TidKategori } from "@/felsokning/domain";
import { TIDKATEGORI_LABEL, handelseRubrik } from "@/felsokning/domain";
import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik";
import {
arAvslutat,
brief,
felbeskrivning,
formateraTid,
foton,
objekt,
overlamningstext,
sistaAktivitet,
tidsfordelning,
tidsfordelningsRader,
} from "@/felsokning/projektioner";
import { metodikForArende, useFelsokning } from "@/felsokning/store";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
const FLIKAR = [
{ id: "guide", label: "Guide" },
{ id: "logg", label: "Logg" },
{ id: "brief", label: "Brief" },
{ id: "rapport", label: "Rapport" },
] as const;
type Flik = (typeof FLIKAR)[number]["id"];
export default function ArendeSida() {
const { id } = useParams<{ id: string }>();
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
const laggTill = useFelsokning((s) => s.laggTill);
const [flik, setFlik] = useState<Flik>("guide");
const [nu, setNu] = useState(() => new Date().toISOString());
useEffect(() => {
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
return () => clearInterval(timer);
}, []);
const metodik = metodikForArende(arende);
if (!arende || !id) {
return (
<FelsokningSkal rubrik="Ärendet hittades inte" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<p className="text-lg text-zinc-300">Ärendet finns inte den här enheten.</p>
</FelsokningSkal>
);
}
const o = objekt(arende);
const avslutat = arAvslutat(arende);
const total = formateraTid(tidsfordelning(arende, nu).totalMs);
const skicka = (h: Handelse) => laggTill(id, h);
return (
<FelsokningSkal
rubrik={`#${arende.nummer} ${o?.beskrivning ?? ""}`}
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
hoger={
<div className="text-right">
<p className="text-lg font-extrabold text-amber-400">{total}</p>
<p className="text-xs font-bold uppercase text-zinc-500">{avslutat ? "Avslutat" : "Pågår"}</p>
</div>
}
>
<nav className="mb-4 grid grid-cols-4 gap-1 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-1">
{FLIKAR.map((f) => (
<button
key={f.id}
onClick={() => setFlik(f.id)}
className={`min-h-12 rounded-md text-base font-extrabold transition-colors ${
flik === f.id ? "bg-amber-400 text-zinc-950" : "text-zinc-300 hover:bg-zinc-800"
}`}
>
{f.label}
</button>
))}
</nav>
{!avslutat && <InaktivitetsBanner arende={arende} nu={nu} skicka={skicka} />}
{flik === "guide" && (
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
)}
{flik === "logg" && <LoggFlik arende={arende} />}
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} />}
</FelsokningSkal>
);
}
// Vid längre inaktivitet ber systemet om sammanhang — svaret blir en loggpost.
function InaktivitetsBanner({
arende,
nu,
skicka,
}: {
arende: Arende;
nu: string;
skicka: (h: Handelse) => void;
}) {
const [text, setText] = useState("");
const sista = sistaAktivitet(arende);
if (!sista) return null;
const minuter = Math.floor((new Date(nu).getTime() - new Date(sista.tidpunkt).getTime()) / 60000);
if (minuter < 20) return null;
return (
<div className="mb-4 rounded-lg border-2 border-amber-400 bg-zinc-900 p-4">
<p className="mb-2 text-lg font-bold text-amber-400">
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
</p>
<p className="mb-3 text-zinc-300">Beskriv kort vad som gjorts under denna period.</p>
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
skicka({ typ: "inaktivitet_forklarad", text: text.trim(), minuter });
setText("");
}}
>
Lägg till i arbetsloggen
</StorKnapp>
</div>
);
}
function GuideFlik({
arende,
metodik,
avslutat,
skicka,
nu,
}: {
arende: Arende;
metodik: Metodik;
avslutat: boolean;
skicka: (h: Handelse) => void;
nu: string;
}) {
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
const [visaOverlamning, setVisaOverlamning] = useState(false);
if (avslutat) {
return (
<Panel rubrik="Felsökningen är avslutad">
<p className="text-lg text-zinc-300">
Ärendet är låst för nya guidesteg. Loggen, briefen och rapporten finns kvar för spårbarhet och export.
</p>
</Panel>
);
}
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
<Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}>
{steg.klart ? (
<>
<p className="mb-3 text-xl font-bold">Samtliga steg i metodiken är dokumenterade.</p>
<p className="mb-4 text-zinc-300">
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
med rekommenderade nästa steg.
</p>
<StorKnapp variant="fara" onClick={() => skicka({ typ: "arende_avslutat" })}>
Avsluta felsökning
</StorKnapp>
</>
) : steg.fraga ? (
<FrageKort key={`${steg.steg.id}/${steg.fraga.id}`} steg={steg} skicka={skicka} />
) : steg.kontroll ? (
<KontrollKort key={`${steg.steg.id}/${steg.kontroll.id}`} steg={steg} skicka={skicka} />
) : null}
</Panel>
<SnabbDokumentation skicka={skicka} />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
Lämna över arbete
</StorKnapp>
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "arende_avslutat" })}>
Avsluta felsökning
</StorKnapp>
</div>
{visaOverlamning && (
<OverlamningDialog arende={arende} metodik={metodik} nu={nu} skicka={skicka} stang={() => setVisaOverlamning(false)} />
)}
</>
);
}
function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
let aktiv: TidKategori = "aktiv_felsokning";
for (const post of arende.handelser) {
if (post.handelse.typ === "kategori_byte") aktiv = post.handelse.kategori;
}
return (
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
{(Object.keys(TIDKATEGORI_LABEL) as TidKategori[]).map((k) => (
<button
key={k}
onClick={() => k !== aktiv && skicka({ typ: "kategori_byte", kategori: k })}
className={`whitespace-nowrap rounded-full border-2 px-4 py-2 text-sm font-extrabold transition-colors ${
k === aktiv
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-300 hover:border-zinc-400"
}`}
>
{TIDKATEGORI_LABEL[k]}
</button>
))}
</div>
);
}
// En fråga i taget, stora svarsknappar.
function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
const fraga = steg.fraga!;
const [text, setText] = useState("");
const svara = (svar: string) =>
skicka({ typ: "fraga_besvarad", stegId: steg.steg.id, frageId: fraga.id, fraga: fraga.text, svar });
return (
<>
<p className="mb-4 text-2xl font-extrabold leading-snug">{fraga.text}</p>
{fraga.svarstyp === "janej" && (
<div className="grid grid-cols-2 gap-2">
<StorKnapp onClick={() => svara("Ja")}>Ja</StorKnapp>
<StorKnapp onClick={() => svara("Nej")}>Nej</StorKnapp>
</div>
)}
{fraga.svarstyp === "val" && (
<div className="grid gap-2">
{fraga.val!.map((v) => (
<StorKnapp key={v} variant="sekundar" onClick={() => svara(v)}>
{v}
</StorKnapp>
))}
</div>
)}
{fraga.svarstyp === "text" && (
<>
<TextFalt label="Svar" varde={text} satt={setText} />
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
Spara svar
</StorKnapp>
</>
)}
</>
);
}
function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
const kontroll = steg.kontroll!;
const [resultat, setResultat] = useState("");
const filRef = useRef<HTMLInputElement>(null);
const arFoto = /fotografera|foto/i.test(kontroll.text);
const utford = () =>
skicka({
typ: "kontroll_utford",
stegId: steg.steg.id,
kontrollId: kontroll.id,
text: kontroll.text,
resultat: resultat.trim() || undefined,
});
return (
<>
{steg.steg.beskrivning && <p className="mb-2 text-zinc-400">{steg.steg.beskrivning}</p>}
<p className="mb-4 text-2xl font-extrabold leading-snug">{kontroll.text}</p>
{arFoto && (
<>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const fil = e.target.files?.[0];
if (!fil) return;
const dataUrl = await skalaNerFoto(fil);
skicka({ typ: "foto", beskrivning: kontroll.text, dataUrl });
utford();
}}
/>
<StorKnapp className="mb-2" onClick={() => filRef.current?.click()}>
📷 Ta / välj foto
</StorKnapp>
</>
)}
<TextFalt label="Resultat / avläst värde (valfritt)" varde={resultat} satt={setResultat} platshallare="T.ex. 2,4 bar samtliga hjul" />
<StorKnapp onClick={utford}>Markera utförd</StorKnapp>
</>
);
}
const DOKTYPER = [
{ id: "observation", label: "Observation" },
{ id: "matvarde", label: "Mätvärde" },
{ id: "foto", label: "Foto" },
{ id: "hypotes", label: "Hypotes" },
{ id: "kommentar", label: "Kommentar" },
] as const;
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
// ej verifierade — de kan aldrig loggas som konstaterade fel.
function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
const [text, setText] = useState("");
const [varde, setVarde] = useState("");
const filRef = useRef<HTMLInputElement>(null);
const aterstall = () => {
setTyp(null);
setText("");
setVarde("");
};
const spara = () => {
const t = text.trim();
if (typ === "observation" && t) skicka({ typ: "observation", text: t });
if (typ === "kommentar" && t) skicka({ typ: "kommentar", text: t });
if (typ === "hypotes" && t) skicka({ typ: "hypotes", text: t, niva: "lag" });
if (typ === "matvarde" && t && varde.trim()) skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
aterstall();
};
return (
<Panel rubrik="Dokumentera">
<div className="grid grid-cols-3 gap-2">
{DOKTYPER.map((d) => (
<button
key={d.id}
onClick={() => (d.id === "foto" ? filRef.current?.click() : setTyp(typ === d.id ? null : d.id))}
className={`min-h-12 rounded-lg border-2 text-sm font-extrabold transition-colors ${
typ === d.id
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-200 hover:border-zinc-400"
}`}
>
+ {d.label}
</button>
))}
</div>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const fil = e.target.files?.[0];
if (!fil) return;
const dataUrl = await skalaNerFoto(fil);
skicka({ typ: "foto", beskrivning: "Foto", dataUrl });
e.target.value = "";
}}
/>
{typ && typ !== "foto" && (
<div className="mt-3">
{typ === "hypotes" && (
<p className="mb-2 text-sm font-bold text-red-400">
🔴 En hypotes är en möjlig felorsak som kräver verifiering den loggas aldrig som ett konstaterat fel.
</p>
)}
<TextFalt
label={typ === "matvarde" ? "Vad mättes?" : DOKTYPER.find((d) => d.id === typ)!.label}
varde={text}
satt={setText}
platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""}
/>
{typ === "matvarde" && <TextFalt label="Värde (med enhet)" varde={varde} satt={setVarde} platshallare="T.ex. 13,9 V" />}
<StorKnapp disabled={!text.trim() || (typ === "matvarde" && !varde.trim())} onClick={spara}>
Spara
</StorKnapp>
</div>
)}
</Panel>
);
}
function OverlamningDialog({
arende,
metodik,
nu,
skicka,
stang,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
skicka: (h: Handelse) => void;
stang: () => void;
}) {
const anvandare = useFelsokning((s) => s.anvandare);
const [till, setTill] = useState("");
const text = overlamningstext(arende, metodik, nu);
return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div className="max-h-full w-full max-w-2xl overflow-y-auto rounded-lg border-2 border-zinc-600 bg-zinc-900 p-4">
<h2 className="mb-3 text-xl font-extrabold">Överlämningsrapport</h2>
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg bg-zinc-950 p-4 text-sm text-zinc-200">
{text}
</pre>
<TextFalt label="Lämnas till (valfritt)" varde={till} satt={setTill} platshallare="T.ex. Johan" />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={stang}>
Avbryt
</StorKnapp>
<StorKnapp
onClick={() => {
skicka({ typ: "overlamning", fran: anvandare, till: till.trim() || undefined });
stang();
}}
>
Bekräfta överlämning
</StorKnapp>
</div>
</div>
</div>
);
}
function LoggFlik({ arende }: { arende: Arende }) {
return (
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
<ol>
{arende.handelser.map((post) => (
<li key={post.id} className="flex gap-3 border-b border-zinc-800 py-2 last:border-0">
<span className="w-14 shrink-0 font-mono text-sm font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>
<div className="min-w-0">
<p className="text-base text-zinc-100">{handelseRubrik(post)}</p>
<p className="text-xs text-zinc-500">{post.anvandare}</p>
{post.handelse.typ === "foto" && (
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-zinc-700" />
)}
</div>
</li>
))}
</ol>
</Panel>
);
}
function BriefFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) {
const b = brief(arende, metodik, nu);
return (
<>
<Panel rubrik="Objekt">
{b.objekt ? (
<>
<p className="text-xl font-extrabold">{b.objekt.beskrivning}</p>
<p className="font-bold text-amber-400">{b.objekt.identifierare}</p>
{b.objekt.kund && <p className="text-zinc-300">Kund: {b.objekt.kund}</p>}
</>
) : (
<p className="text-zinc-400">Ej identifierat</p>
)}
</Panel>
<Panel rubrik="Kundens beskrivning">
<p className="text-lg">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Utförda kontroller">
{b.utfordaKontroller.length === 0 && <p className="text-zinc-400">Inga ännu.</p>}
{b.utfordaKontroller.map((k, i) => (
<p key={i} className="py-0.5 text-lg">
{k.text}
{k.resultat && <span className="text-zinc-400"> {k.resultat}</span>}
</p>
))}
</Panel>
<Panel rubrik="Observationer">
{b.observationer.length === 0 && <p className="text-zinc-400">Inga ännu.</p>}
{b.observationer.map((o, i) => (
<p key={i} className="py-0.5 text-lg"> {o}</p>
))}
</Panel>
{b.hypoteser.length > 0 && (
<Panel rubrik="Hypoteser — kräver verifiering">
{b.hypoteser.map((h, i) => (
<p key={i} className="py-0.5 text-lg">
<NivaBadge niva={h.niva} /> {h.text}
</p>
))}
</Panel>
)}
<Panel rubrik="Ej kontrollerat">
{b.ejKontrollerat.length === 0 && <p className="text-zinc-400">Allt i metodiken är utfört.</p>}
{b.ejKontrollerat.map((e, i) => (
<p key={i} className="py-0.5 text-lg text-zinc-300"> {e}</p>
))}
</Panel>
{!b.avslutat && (
<Panel rubrik="Rekommenderat nästa steg">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-lg">{i + 1}. {s}</p>
))}
</Panel>
)}
<Panel rubrik="Tillförlitlighet">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-lg">
<NivaBadge niva={r.niva} /> {r.text}
</p>
))}
</Panel>
<Panel rubrik="Total arbetstid">
<p className="text-2xl font-extrabold">{b.totalArbetstid}</p>
</Panel>
</>
);
}
// Kundrapporten: en tydlig tidslinje i stället för "Felsökning 2,5 timmar".
// Interna poster (kategoribyten) visas inte för kund.
function RapportFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) {
const b = brief(arende, metodik, nu);
const bilder = foton(arende);
const fordelning = tidsfordelningsRader(arende, nu);
const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte");
return (
<>
<Panel rubrik="Kundrapport">
<p className="mb-2 text-zinc-300">
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas bilder kan
innehålla uppgifter om andra kunder.
</p>
<StorKnapp variant="sekundar" onClick={() => window.print()}>
Skriv ut / spara som PDF
</StorKnapp>
</Panel>
<Panel rubrik={`Ärende #${arende.nummer}`}>
{b.objekt && (
<p className="text-lg font-bold">
{b.objekt.beskrivning} · {b.objekt.identifierare}
</p>
)}
{b.felbeskrivning && <p className="mt-1 text-lg">Felbeskrivning: {b.felbeskrivning}</p>}
<p className="mt-1 text-lg">Total arbetstid: <span className="font-extrabold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && (
<div className="mt-2">
{fordelning.map((r) => (
<p key={r.label} className="text-zinc-300">{r.label}: {r.tid}</p>
))}
</div>
)}
</Panel>
<Panel rubrik="Tidslinje">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-base">
<span className="font-mono font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>{" "}
{handelseRubrik(post)}
</p>
))}
</Panel>
{bilder.length > 0 && (
<Panel rubrik="Dokumenterade bilder">
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-zinc-700" />
<figcaption className="mt-1 text-xs text-zinc-400">{bild.beskrivning}</figcaption>
</figure>
))}
</div>
</Panel>
)}
{!b.avslutat || b.ejKontrollerat.length > 0 ? (
<Panel rubrik="Rekommenderade nästa steg">
{(b.avslutat ? b.ejKontrollerat : b.rekommenderatNastaSteg).map((s, i) => (
<p key={i} className="py-0.5 text-lg">{i + 1}. {s}</p>
))}
</Panel>
) : null}
<p className="text-center text-xs text-zinc-600">
Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.
</p>
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useFelsokning, metodikForArende } from "@/felsokning/store";
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { tidDatum, tidKlockslag } from "@/felsokning/format";
export default function Arendelista() {
const { anvandare, arenden, sattAnvandare } = useFelsokning();
const [namn, setNamn] = useState("");
if (!anvandare) {
return (
<FelsokningSkal rubrik="Guidad Felsökning">
<Panel rubrik="Vem arbetar?">
<p className="mb-3 text-lg text-zinc-300">
Allt arbete loggas med användare och tidpunkt. Ange ditt namn för att börja.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
if (namn.trim()) sattAnvandare(namn);
}}
>
<TextFalt label="Namn" varde={namn} satt={setNamn} platshallare="T.ex. Anna" />
<StorKnapp type="submit" disabled={!namn.trim()}>
Fortsätt
</StorKnapp>
</form>
</Panel>
</FelsokningSkal>
);
}
const lista = Object.values(arenden).sort((a, b) => b.skapad.localeCompare(a.skapad));
return (
<FelsokningSkal
rubrik="Guidad Felsökning"
hoger={<span className="text-sm font-bold text-zinc-400">{anvandare}</span>}
>
<Link to="/felsokning/nytt">
<StorKnapp className="mb-5">+ Nytt ärende</StorKnapp>
</Link>
{lista.length === 0 && (
<p className="text-center text-lg text-zinc-400">
Inga ärenden ännu. Starta med att identifiera ett objekt.
</p>
)}
{lista.map((arende) => {
const o = objekt(arende);
const fel = felbeskrivning(arende);
const sista = sistaAktivitet(arende);
const avslutat = arAvslutat(arende);
const b = brief(arende, metodikForArende(arende), new Date().toISOString());
return (
<Link key={arende.id} to={`/felsokning/arende/${arende.id}`} className="block">
<div className="mb-3 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4 transition-colors hover:border-amber-400">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">
Ärende #{arende.nummer}
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-extrabold uppercase ${
avslutat ? "bg-zinc-700 text-zinc-300" : "bg-amber-400 text-zinc-950"
}`}
>
{avslutat ? "Avslutat" : "Pågående"}
</span>
</div>
<p className="mt-1 text-xl font-extrabold">{o ? o.beskrivning : "Okänt objekt"}</p>
{o && <p className="text-sm font-bold text-zinc-400">{o.identifierare}{o.kund ? ` · ${o.kund}` : ""}</p>}
{fel && <p className="mt-2 text-lg text-zinc-200">{fel}</p>}
<p className="mt-2 text-sm text-zinc-500">
{sista ? `Senaste aktivitet ${tidDatum(sista.tidpunkt)} ${tidKlockslag(sista.tidpunkt)}` : ""}
{" · "}
{b.totalArbetstid}
</p>
</div>
</Link>
);
})}
</FelsokningSkal>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Objekt } from "@/felsokning/domain";
import { useFelsokning } from "@/felsokning/store";
import { valjMetodik } from "@/felsokning/metodik";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
const IDENTIFIERINGSMETODER = ["Registreringsnummer", "VIN", "Serienummer", "Maskinnummer", "Manuell inmatning"];
const OBJEKTTYPER = ["Fordon", "Lastbil/Buss", "Entreprenadmaskin", "Industrimaskin", "Elsystem", "Hydraulik", "Övrigt"];
// Ingen felsökning börjar innan objektet identifierats och bekräftats.
export default function NyttArende() {
const navigate = useNavigate();
const skapaArende = useFelsokning((s) => s.skapaArende);
const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera");
const [metod, setMetod] = useState(IDENTIFIERINGSMETODER[0]);
const [typ, setTyp] = useState(OBJEKTTYPER[0]);
const [identifierare, setIdentifierare] = useState("");
const [beskrivning, setBeskrivning] = useState("");
const [kund, setKund] = useState("");
const [fel, setFel] = useState("");
const objektet: Objekt = {
typ,
identifierare: identifierare.trim().toUpperCase(),
identifieringsmetod: metod,
beskrivning: beskrivning.trim() || identifierare.trim().toUpperCase(),
kund: kund.trim() || undefined,
};
if (steg === "identifiera") {
return (
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Objekttyp">
<div className="grid grid-cols-2 gap-2">
{OBJEKTTYPER.map((t) => (
<StorKnapp key={t} variant={t === typ ? "primar" : "sekundar"} onClick={() => setTyp(t)}>
{t}
</StorKnapp>
))}
</div>
</Panel>
<Panel rubrik="Identifieringsmetod">
<div className="grid grid-cols-2 gap-2">
{IDENTIFIERINGSMETODER.map((m) => (
<StorKnapp key={m} variant={m === metod ? "primar" : "sekundar"} onClick={() => setMetod(m)}>
{m}
</StorKnapp>
))}
</div>
<p className="mt-3 text-sm text-zinc-500">
QR-kod, streckkod och OCR från typskylt tillkommer i senare version ange identiteten manuellt.
</p>
</Panel>
<Panel rubrik="Objektets identitet">
<TextFalt label={metod} varde={identifierare} satt={setIdentifierare} platshallare="T.ex. ABC123" />
<TextFalt label="Beskrivning (märke, modell, år)" varde={beskrivning} satt={setBeskrivning} platshallare="T.ex. Volvo XC60 D4 2019" />
<TextFalt label="Kund (valfritt)" varde={kund} satt={setKund} platshallare="T.ex. Anders Svensson" />
<StorKnapp disabled={!identifierare.trim()} onClick={() => setSteg("bekrafta")}>
Fortsätt
</StorKnapp>
</Panel>
</FelsokningSkal>
);
}
if (steg === "bekrafta") {
return (
<FelsokningSkal rubrik="Bekräfta objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">{objektet.typ}</p>
<p className="mt-1 text-3xl font-extrabold">{objektet.beskrivning}</p>
<p className="mt-1 text-xl font-bold text-amber-400">{objektet.identifierare}</p>
<p className="mt-1 text-sm text-zinc-400">Identifierad via: {objektet.identifieringsmetod}</p>
{objektet.kund && <p className="mt-1 text-lg text-zinc-200">Kund: {objektet.kund}</p>}
</Panel>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
Ändra
</StorKnapp>
<StorKnapp onClick={() => setSteg("felbeskrivning")}>Rätt objekt fortsätt</StorKnapp>
</div>
</FelsokningSkal>
);
}
const metodik = fel.trim() ? valjMetodik(fel) : undefined;
return (
<FelsokningSkal rubrik="Beskriv felet" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik={`${objektet.beskrivning} · ${objektet.identifierare}`}>
<TextFalt
label="Kundens/användarens felbeskrivning"
varde={fel}
satt={setFel}
platshallare="T.ex. Bilen vibrerar runt 88 km/h"
flerRad
/>
{metodik && (
<p className="mb-3 text-sm font-bold text-zinc-400">
Vald metodik: <span className="text-amber-400">{metodik.namn}</span>
</p>
)}
<StorKnapp
disabled={!fel.trim()}
onClick={() => {
const id = skapaArende(objektet, fel.trim());
navigate(`/felsokning/arende/${id}`);
}}
>
Starta arbetslogg
</StorKnapp>
</Panel>
</FelsokningSkal>
);
}