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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user