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
+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>
</>
);
}