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 { synkroniseraArende, type SynkStatus } from "@/felsokning/synk"; import { AI_MODELL, AI_RADTYP_LABEL, fragaAi } from "@/felsokning/ai"; 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"]; const SYNKSTATUS_LABEL: Record = { lokal: "Lokalt läge", synkar: "Synkar …", synkad: "Synkad", offline: "Offline", }; // Synkar ärendet mot backend: direkt vid nya händelser och därefter var // 15:e sekund (hämtar kollegors händelser). Utan inloggning: lokalt läge. function useSynk(arende: Arende | undefined): SynkStatus { const [status, setStatus] = useState("lokal"); const sammanfoga = useFelsokning((s) => s.sammanfoga); const arendeId = arende?.id; const antal = arende?.handelser.length ?? 0; useEffect(() => { if (!arende || !arendeId) return; let aktiv = true; const kor = async () => { setStatus((s) => (s === "synkad" ? "synkar" : s)); const resultat = await synkroniseraArende(arende); if (!aktiv) return; setStatus(resultat.status); if (resultat.handelser) sammanfoga(arendeId, resultat.handelser); }; kor(); const timer = setInterval(kor, 15000); return () => { aktiv = false; clearInterval(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- antal driver omsynk vid nya händelser }, [arendeId, antal, sammanfoga]); return status; } 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("guide"); const [nu, setNu] = useState(() => new Date().toISOString()); const synkStatus = useSynk(arende); useEffect(() => { const timer = setInterval(() => setNu(new Date().toISOString()), 30000); return () => clearInterval(timer); }, []); const metodik = metodikForArende(arende); if (!arende || !id) { return (

Ärendet finns inte på den här enheten.

); } const o = objekt(arende); const avslutat = arAvslutat(arende); const total = formateraTid(tidsfordelning(arende, nu).totalMs); const skicka = (h: Handelse) => laggTill(id, h); return (

{total}

{avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]}

} > {!avslutat && } {flik === "guide" && ( )} {flik === "logg" && } {flik === "brief" && } {flik === "rapport" && }
); } // 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 (

Ingen aktivitet har registrerats de senaste {minuter} minuterna.

Beskriv kort vad som gjorts under denna period.

{ skicka({ typ: "inaktivitet_forklarad", text: text.trim(), minuter }); setText(""); }} > Lägg till i arbetsloggen
); } 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); const aiNyckel = useFelsokning((s) => s.aiNyckel); const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar"); // AI:n svarar skriftligt på varje bekräftad dokumentation: klassificerat // (observation/verifierat/hypotes/rekommendation) och loggat som händelse. // Utan nyckel guidar den deterministiska metodiken ensam. const fragaAiOmDokumentation = async (inmatning: string) => { if (!aiNyckel) return; setAiStatus("arbetar"); try { const svar = await fragaAi(aiNyckel, brief(arende, metodik, nu), metodik.namn, inmatning); skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: AI_MODELL }); setAiStatus("vilar"); } catch { setAiStatus("fel"); } }; const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar"); if (avslutat) { return (

Ärendet är låst för nya guidesteg. Loggen, briefen och rapporten finns kvar för spårbarhet och export.

); } return ( <> {steg.klart ? ( <>

Samtliga steg i metodiken är dokumenterade.

Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet med rekommenderade nästa steg.

skicka({ typ: "arende_avslutat" })}> Avsluta felsökning ) : steg.fraga ? ( ) : steg.kontroll ? ( ) : null}
{(aiStatus !== "vilar" || senasteAiSvar) && ( {aiStatus === "arbetar" &&

AI analyserar …

} {aiStatus === "fel" && (

AI-svaret kunde inte hämtas — kontrollera nyckel och nät. Metodiken fortsätter som vanligt.

)} {senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && ( <> {senasteAiSvar.handelse.rader.map((rad, i) => (

{AI_RADTYP_LABEL[rad.typ]}: {rad.text}

))}

Nästa steg:{" "} {senasteAiSvar.handelse.nastaSteg}

)}
)}
setVisaOverlamning(true)}> Lämna över arbete skicka({ typ: "arende_avslutat" })}> Avsluta felsökning
{visaOverlamning && ( 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 (
{(Object.keys(TIDKATEGORI_LABEL) as TidKategori[]).map((k) => ( ))}
); } // 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 ( <>

{fraga.text}

{fraga.svarstyp === "janej" && (
svara("Ja")}>Ja svara("Nej")}>Nej
)} {fraga.svarstyp === "val" && (
{fraga.val!.map((v) => ( svara(v)}> {v} ))}
)} {fraga.svarstyp === "text" && ( <> svara(text.trim())}> Spara svar )} ); } // Verifierad checklista: en kontrollpunkt är inte slutförd enbart genom en // kryssruta — minimikravet (foto, mätvärde eller kort observation) styrs av // kontrolltypen i metodiken. function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) { const kontroll = steg.kontroll!; const [resultat, setResultat] = useState(""); const filRef = useRef(null); const krav = kontroll.krav; const utford = () => skicka({ typ: "kontroll_utford", stegId: steg.steg.id, kontrollId: kontroll.id, text: kontroll.text, resultat: resultat.trim() || undefined, }); const kravUppfyllt = krav === "foto" || !krav || resultat.trim().length > 0; return ( <> {steg.steg.beskrivning &&

{steg.steg.beskrivning}

}

{kontroll.text}

{krav === "foto" ? ( <> { const fil = e.target.files?.[0]; if (!fil) return; const dataUrl = await skalaNerFoto(fil); skicka({ typ: "foto", beskrivning: kontroll.text, dataUrl }); utford(); }} /> filRef.current?.click()}>📷 Ta foto — verifierar kontrollen

Denna kontroll verifieras med foto.

) : ( <> {!kravUppfyllt && (

Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats — lägg till en kort{" "} {krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.

)} Markera verifierad )} ); } 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. // paSparad anropas med en textsammanfattning EFTER att användaren bekräftat // med Spara — det är den texten AI:n får (aldrig obekräftad inmatning). function SnabbDokumentation({ skicka, paSparad, }: { skicka: (h: Handelse) => void; paSparad?: (sammanfattning: string) => void; }) { const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null); const [text, setText] = useState(""); const [varde, setVarde] = useState(""); const filRef = useRef(null); const aterstall = () => { setTyp(null); setText(""); setVarde(""); }; const spara = () => { const t = text.trim(); if (typ === "observation" && t) { skicka({ typ: "observation", text: t }); paSparad?.(`Observation: ${t}`); } if (typ === "kommentar" && t) { skicka({ typ: "kommentar", text: t }); paSparad?.(t); } if (typ === "hypotes" && t) { skicka({ typ: "hypotes", text: t, niva: "lag" }); paSparad?.(`Teknikerns hypotes (ej verifierad): ${t}`); } if (typ === "matvarde" && t && varde.trim()) { skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() }); paSparad?.(`Mätvärde: ${t} = ${varde.trim()}`); } aterstall(); }; return (
{DOKTYPER.map((d) => ( ))}
{ 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" && (
{typ === "hypotes" && (

🔴 En hypotes är en möjlig felorsak som kräver verifiering — den loggas aldrig som ett konstaterat fel.

)} d.id === typ)!.label} varde={text} satt={setText} platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""} rost /> {typ === "matvarde" && } Spara
)}
); } 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 (

Överlämningsrapport

          {text}
        
Avbryt { skicka({ typ: "overlamning", fran: anvandare, till: till.trim() || undefined }); stang(); }} > Bekräfta överlämning
); } function LoggFlik({ arende }: { arende: Arende }) { return (
    {arende.handelser.map((post) => (
  1. {tidKlockslag(post.tidpunkt)}

    {handelseRubrik(post)}

    {post.anvandare}

    {post.handelse.typ === "foto" && ( {post.handelse.beskrivning} )}
  2. ))}
); } function BriefFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) { const b = brief(arende, metodik, nu); return ( <> {b.objekt ? ( <>

{b.objekt.beskrivning}

{b.objekt.identifierare}

{b.objekt.kund &&

Kund: {b.objekt.kund}

} ) : (

Ej identifierat

)}

{b.felbeskrivning ? `”${b.felbeskrivning}”` : "—"}

{b.utfordaKontroller.length === 0 &&

Inga ännu.

} {b.utfordaKontroller.map((k, i) => (

✓ {k.text} {k.resultat && — {k.resultat}}

))}
{b.observationer.length === 0 &&

Inga ännu.

} {b.observationer.map((o, i) => (

• {o}

))}
{b.hypoteser.length > 0 && ( {b.hypoteser.map((h, i) => (

{h.text}

))}
)} {b.ejKontrollerat.length === 0 &&

Allt i metodiken är utfört.

} {b.ejKontrollerat.map((e, i) => (

– {e}

))}
{!b.avslutat && ( {b.rekommenderatNastaSteg.map((s, i) => (

{i + 1}. {s}

))}
)} {b.tillforlitlighet.map((r, i) => (

{r.text}

))}

{b.totalArbetstid}

); } // 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, skicka, }: { arende: Arende; metodik: Metodik; nu: string; skicka: (h: Handelse) => void; }) { const anvandare = useFelsokning((s) => s.anvandare); const b = brief(arende, metodik, nu); const bilder = foton(arende); const fordelning = tidsfordelningsRader(arende, nu); // Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial // och ingår inte i det som delas med kund. const kundposter = arende.handelser.filter( (p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ), ); // Alla exporter bygger på samma händelselogg och versionsmärks: // version = antal händelser vid exporttillfället. const exporteraJson = () => { const version = arende.handelser.length; const data = { export: { format: "JSON", version, exporteradAv: anvandare, tidpunkt: new Date().toISOString() }, arende, }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const lank = document.createElement("a"); lank.href = url; lank.download = `arende-${arende.nummer}-v${version}.json`; lank.click(); URL.revokeObjectURL(url); skicka({ typ: "export_skapad", format: "JSON", version }); }; return ( <>

Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas — bilder kan innehålla uppgifter om andra kunder.

window.print()}> Skriv ut / PDF Exportera JSON
🟢 Öppna Live Share-vy {arende.delningskod && ( { navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`); skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." }); }} > 🔗 Kopiera delningslänk )}

Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).

{b.objekt && (

{b.objekt.beskrivning} · {b.objekt.identifierare}

)} {b.felbeskrivning &&

Felbeskrivning: ”{b.felbeskrivning}”

}

Total arbetstid: {b.totalArbetstid}

{fordelning.length > 0 && (
{fordelning.map((r) => (

{r.label}: {r.tid}

))}
)}
{kundposter.map((post) => (

{tidKlockslag(post.tidpunkt)}{" "} {handelseRubrik(post)}

))}
{bilder.length > 0 && (
{bilder.map((bild, i) => (
{bild.beskrivning}
{bild.beskrivning}
))}
)} {!b.avslutat || b.ejKontrollerat.length > 0 ? ( {(b.avslutat ? b.ejKontrollerat : b.rekommenderatNastaSteg).map((s, i) => (

{i + 1}. {s}

))}
) : null}

Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.

); }