2202737d48
- AI-motor (ai.ts): Claude Opus 5 via officiella TypeScript-SDK:n. Teknikerns bekräftade dokumentation skickas med ärendebriefen som kontext; svaret är schema-bundet (structured outputs) och strikt klassificerat enligt AI-reglerna: observation, verifierat, hypotes (kräver alltid verifiering) och rekommendation, plus ETT konkret nästa steg. Fryst systemprompt med promptcache; automatisk server-side fallback till Anthropics rekommenderade reservmodell när säkerhetsklassificerarna avböjer; refusal hanteras innan innehållet läses. - Onboarding enligt direktivet: organisationens Claude API-nyckel läggs in på startsidan (maskerat fält). Utan nyckel guidar den deterministiska metodiken ensam. MVP-varning: nyckeln lagras på enheten och anropet görs från webbläsaren — i produktion via backend. - AI-svaren loggas som händelser med modellnamn och visas i guiden; de är arbetsmaterial och filtreras ur kundrapport, Live Share och databasens delningsfunktion, precis som hypoteser. - Master Prompt uppdaterad: AI-wrapper ovanpå Claude API, standard- modell claude-opus-5. Verifierat: 25 vitest-tester gröna (inkl. promptbygge, svarstolkning och systemprompt-regler), produktionsbygge ok, Playwright-röktest genom hela flödet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
778 lines
28 KiB
TypeScript
778 lines
28 KiB
TypeScript
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<SynkStatus, string> = {
|
||
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<SynkStatus>("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<Flik>("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 (
|
||
<FelsokningSkal rubrik="Ärendet hittades inte" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||
<p className="text-lg text-zinc-300">Ärendet finns inte på 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"} · {SYNKSTATUS_LABEL[synkStatus]}
|
||
</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} skicka={skicka} />}
|
||
</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." rost />
|
||
<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);
|
||
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 (
|
||
<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>
|
||
|
||
{(aiStatus !== "vilar" || senasteAiSvar) && (
|
||
<Panel rubrik={`AI-handledning (${AI_MODELL})`}>
|
||
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar …</p>}
|
||
{aiStatus === "fel" && (
|
||
<p className="mb-2 font-bold text-red-400">AI-svaret kunde inte hämtas — kontrollera nyckel och nät. Metodiken fortsätter som vanligt.</p>
|
||
)}
|
||
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
|
||
<>
|
||
{senasteAiSvar.handelse.rader.map((rad, i) => (
|
||
<p key={i} className="py-0.5 text-lg">
|
||
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
|
||
</p>
|
||
))}
|
||
<p className="mt-2 text-lg">
|
||
<span className="font-extrabold text-amber-400">Nästa steg:</span>{" "}
|
||
{senasteAiSvar.handelse.nastaSteg}
|
||
</p>
|
||
</>
|
||
)}
|
||
</Panel>
|
||
)}
|
||
|
||
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
|
||
|
||
<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} rost />
|
||
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
|
||
Spara svar
|
||
</StorKnapp>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 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<HTMLInputElement>(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 && <p className="mb-2 text-zinc-400">{steg.steg.beskrivning}</p>}
|
||
<p className="mb-4 text-2xl font-extrabold leading-snug">{kontroll.text}</p>
|
||
{krav === "foto" ? (
|
||
<>
|
||
<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();
|
||
}}
|
||
/>
|
||
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
|
||
<StorKnapp onClick={() => filRef.current?.click()}>📷 Ta foto — verifierar kontrollen</StorKnapp>
|
||
<p className="mt-2 text-sm text-zinc-500">Denna kontroll verifieras med foto.</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<TextFalt
|
||
label={
|
||
krav === "matvarde"
|
||
? "Uppmätt värde (krävs för verifiering)"
|
||
: krav === "kommentar"
|
||
? "Vad observerades? (krävs för verifiering)"
|
||
: "Resultat / avläst värde (valfritt)"
|
||
}
|
||
varde={resultat}
|
||
satt={setResultat}
|
||
platshallare={krav === "matvarde" ? "T.ex. 2,4 bar samtliga hjul" : "T.ex. Säkringen är hel, spänning på båda sidor"}
|
||
rost
|
||
/>
|
||
{!kravUppfyllt && (
|
||
<p className="mb-3 text-sm font-bold text-amber-400">
|
||
Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats — lägg till en kort{" "}
|
||
{krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.
|
||
</p>
|
||
)}
|
||
<StorKnapp disabled={!kravUppfyllt} onClick={utford}>
|
||
Markera verifierad
|
||
</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.
|
||
// 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<HTMLInputElement>(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 (
|
||
<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" : ""}
|
||
rost
|
||
/>
|
||
{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,
|
||
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 (
|
||
<>
|
||
<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>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<StorKnapp variant="sekundar" onClick={() => window.print()}>
|
||
Skriv ut / PDF
|
||
</StorKnapp>
|
||
<StorKnapp variant="sekundar" onClick={exporteraJson}>
|
||
Exportera JSON
|
||
</StorKnapp>
|
||
</div>
|
||
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
|
||
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
|
||
</Link>
|
||
{arende.delningskod && (
|
||
<StorKnapp
|
||
variant="sekundar"
|
||
className="mt-2"
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`);
|
||
skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." });
|
||
}}
|
||
>
|
||
🔗 Kopiera delningslänk
|
||
</StorKnapp>
|
||
)}
|
||
<p className="mt-2 text-sm text-zinc-500">
|
||
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
|
||
</p>
|
||
</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>
|
||
</>
|
||
);
|
||
}
|