Backend-grund, synklager, Live Share-vy och elsystemmetodik

- Supabase-migration: felsokning_arenden + felsokning_handelser med
  RLS för inloggade och append-only garanterat i databasen (inga
  update/delete-rättigheter). Live Share förberedd via security
  definer-funktionen hamta_delat_arende(delningskod) så att anonyma
  läsare aldrig når tabellerna direkt; kategoribyten filtreras bort.
  Realtime-publicering aktiverad för händelsetabellen.
- Synklager i klienten (synk.ts): deterministisk, konfliktfri
  ihopflätning av händelser per id (testad: dedupe, tidsordning,
  idempotens), push av lokala och pull av kollegors händelser var
  15:e sekund. Utan inloggning: lokalt läge, status i ärendehuvudet.
- Live Share-vy (/felsokning/dela/:id): skrivskyddad livevy med
  status ✔/🔄/, bilder, mätvärdestabell, tidslinje och rekommenderat
  nästa steg. Varje ärende får slumpad delningskod vid skapande.
- Ny metodik: Elsystem/strömförsörjning enligt relä-exemplet i
  visionen, med mätvärdeskrav på alla mätpunkter.

Verifierat: 17 vitest-tester gröna, produktionsbygge ok, Playwright-
röktest inkl. Live Share-vyn.

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:48:03 +00:00
parent 7b6619133f
commit fcded6bd43
11 changed files with 488 additions and 7 deletions
+45 -1
View File
@@ -17,6 +17,7 @@ import {
tidsfordelningsRader,
} from "@/felsokning/projektioner";
import { metodikForArende, useFelsokning } from "@/felsokning/store";
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -29,12 +30,50 @@ const FLIKAR = [
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);
@@ -63,7 +102,9 @@ export default function ArendeSida() {
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>
<p className="text-xs font-bold uppercase text-zinc-500">
{avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]}
</p>
</div>
}
>
@@ -600,6 +641,9 @@ function RapportFlik({
Exportera JSON
</StorKnapp>
</div>
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
</Link>
</Panel>
<Panel rubrik={`Ärende #${arende.nummer}`}>
{b.objekt && (
+137
View File
@@ -0,0 +1,137 @@
// Live Share: skrivskyddad livevy av ett ärende — det kunden, arbetsledaren
// eller en extern partner ser via delningslänken. Uppdateras automatiskt
// när ny information registreras. Interna poster (kategoribyten) visas inte.
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { handelseRubrik } from "@/felsokning/domain";
import { arAvslutat, brief, foton, tidsfordelningsRader } from "@/felsokning/projektioner";
import { metodikForArende, useFelsokning } from "@/felsokning/store";
import { tidDatum, tidKlockslag } from "@/felsokning/format";
import { FelsokningSkal, Panel } from "@/felsokning/ui";
export default function DelatArende() {
const { id } = useParams<{ id: string }>();
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
const [nu, setNu] = useState(() => new Date().toISOString());
useEffect(() => {
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
return () => clearInterval(timer);
}, []);
if (!arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<p className="text-lg text-zinc-300">Ärendet är inte tillgängligt.</p>
</FelsokningSkal>
);
}
const metodik = metodikForArende(arende);
const b = brief(arende, metodik, nu);
const avslutat = arAvslutat(arende);
const bilder = foton(arende);
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte");
const pagaende = b.rekommenderatNastaSteg[0];
return (
<FelsokningSkal
rubrik={b.objekt?.beskrivning ?? "Ärende"}
hoger={
<span
className={`rounded px-3 py-1 text-sm font-extrabold uppercase ${
avslutat ? "bg-zinc-700 text-zinc-200" : "bg-green-500 text-zinc-950"
}`}
>
{avslutat ? "Avslutat" : "🟢 Felsökning pågår"}
</span>
}
>
<p className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-3 text-sm text-zinc-400">
Skrivskyddad livevy uppdateras automatiskt när verkstaden registrerar ny information. Extern delning
via säker länk aktiveras när backend är i drift; detta är samma vy.
</p>
{b.felbeskrivning && (
<Panel rubrik="Kundens felbeskrivning">
<p className="text-lg">{b.felbeskrivning}</p>
</Panel>
)}
<Panel rubrik="Aktuell status">
{b.utfordaKontroller.map((k, i) => (
<p key={`u${i}`} className="py-0.5 text-lg"> {k.text}</p>
))}
{!avslutat && pagaende && <p className="py-0.5 text-lg text-amber-400">🔄 {pagaende}</p>}
{b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => (
<p key={`e${i}`} className="py-0.5 text-lg text-zinc-400"> {e}</p>
))}
</Panel>
{bilder.length > 0 && (
<Panel rubrik="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>
)}
{matvarden.length > 0 && (
<Panel rubrik="Mätvärden">
<table className="w-full text-lg">
<tbody>
{matvarden.map((p) => {
const h = p.handelse;
if (h.typ !== "matvarde") return null;
return (
<tr key={p.id} className="border-b border-zinc-800 last:border-0">
<td className="py-1 pr-3 text-zinc-300">{h.beskrivning}</td>
<td className="py-1 font-bold">
{h.varde}
{h.enhet ? ` ${h.enhet}` : ""}
</td>
</tr>
);
})}
</tbody>
</table>
</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>
{!avslutat && pagaende && (
<Panel rubrik="Rekommenderat nästa steg">
<p className="text-lg">{pagaende}</p>
</Panel>
)}
<Panel rubrik="Arbetstid">
<p className="text-2xl font-extrabold">{b.totalArbetstid}</p>
{tidsfordelningsRader(arende, nu).map((r) => (
<p key={r.label} className="text-zinc-300">
{r.label}: {r.tid}
</p>
))}
</Panel>
<p className="text-center text-xs text-zinc-600">
Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg
</p>
</FelsokningSkal>
);
}