Demoklart: demoärende, dashboard-filter, publik delningssida, utskrift

- Demoärende med ett klick: komplett vibrationsärende (Volvo XC60)
  med 1 tim 35 min historik — besvarade symptomfrågor, fyra hjulfoton,
  mätvärden, provkörning, inaktivitetsförklaring, överlämning mellan
  två tekniker och en hypotes. Testat: tidsordning, arbetstid, kvar-
  varande kontroller så guiden kan fortsätta live i demon.
- Dashboard enligt direktivet: räknare och filter Alla/Pågående/Klara.
- Publik delningssida /felsokning/delad/:kod som läser via
  hamta_delat_arende utan inloggning och pollar för liveuppdatering;
  delad vykomponent med interna förhandsvisningen; Kopiera
  delningslänk i rapportfliken.
- Utskriftsvänlig kundrapport: svart på vitt vid utskrift, knappar
  och fält döljs automatiskt.
- Demomanus i docs/DEMO.md med steg-för-steg-flöde och tabell över
  demoläge kontra produktion.

Verifierat: 21 vitest-tester gröna, produktionsbygge ok, Playwright-
röktest inkl. demoflödet i färsk profil.

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-03 00:49:21 +00:00
parent 8908224a01
commit a33e2bfcbf
13 changed files with 540 additions and 128 deletions
+15
View File
@@ -648,6 +648,21 @@ function RapportFlik({
<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 && (
+48 -7
View File
@@ -2,12 +2,18 @@ import { useState } from "react";
import { Link } from "react-router-dom";
import { useFelsokning, metodikForArende } from "@/felsokning/store";
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
import { byggDemoArende } from "@/felsokning/demo";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { tidDatum, tidKlockslag } from "@/felsokning/format";
type Filter = "alla" | "pagaende" | "klara";
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
// pågående, klara och starta nytt ärende.
export default function Arendelista() {
const { anvandare, arenden, sattAnvandare } = useFelsokning();
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer } = useFelsokning();
const [namn, setNamn] = useState("");
const [filter, setFilter] = useState<Filter>("alla");
if (!anvandare) {
return (
@@ -32,7 +38,16 @@ export default function Arendelista() {
);
}
const lista = Object.values(arenden).sort((a, b) => b.skapad.localeCompare(a.skapad));
const alla = Object.values(arenden).sort((a, b) => b.skapad.localeCompare(a.skapad));
const pagaende = alla.filter((a) => !arAvslutat(a));
const klara = alla.filter((a) => arAvslutat(a));
const lista = filter === "pagaende" ? pagaende : filter === "klara" ? klara : alla;
const FILTER: { id: Filter; label: string; antal: number }[] = [
{ id: "alla", label: "Alla", antal: alla.length },
{ id: "pagaende", label: "Pågående", antal: pagaende.length },
{ id: "klara", label: "Klara", antal: klara.length },
];
return (
<FelsokningSkal
@@ -40,13 +55,39 @@ export default function Arendelista() {
hoger={<span className="text-sm font-bold text-zinc-400">{anvandare}</span>}
>
<Link to="/felsokning/nytt">
<StorKnapp className="mb-5">+ Nytt ärende</StorKnapp>
<StorKnapp className="mb-4">+ Nytt ärende</StorKnapp>
</Link>
{lista.length === 0 && (
<p className="text-center text-lg text-zinc-400">
Inga ärenden ännu. Starta med att identifiera ett objekt.
</p>
{alla.length > 0 && (
<div className="mb-4 grid grid-cols-3 gap-1 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-1">
{FILTER.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`min-h-12 rounded-md text-base font-extrabold transition-colors ${
filter === f.id ? "bg-amber-400 text-zinc-950" : "text-zinc-300 hover:bg-zinc-800"
}`}
>
{f.label} · {f.antal}
</button>
))}
</div>
)}
{alla.length === 0 && (
<Panel rubrik="Kom igång">
<p className="mb-3 text-lg text-zinc-300">
Inga ärenden ännu. Starta med att identifiera ett objekt eller utforska ett färdigt demoärende
med komplett arbetslogg, brief och kundrapport.
</p>
<StorKnapp variant="sekundar" onClick={() => laggInArende(byggDemoArende(nastaNummer, anvandare))}>
Skapa demoärende (Volvo XC60, vibration)
</StorKnapp>
</Panel>
)}
{lista.length === 0 && alla.length > 0 && (
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
)}
{lista.map((arende) => {
+11 -116
View File
@@ -1,14 +1,12 @@
// 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.
// Intern förhandsvisning av Live Share-vyn: läser ur lokala storen och
// uppdateras live medan teknikern arbetar. Den publika delningslänken
// (via delningskod) renderar exakt samma vy.
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";
import { DelatArendeVy } from "@/felsokning/DelatArendeVy";
import { useFelsokning } from "@/felsokning/store";
import { FelsokningSkal } from "@/felsokning/ui";
export default function DelatArende() {
const { id } = useParams<{ id: string }>();
@@ -28,114 +26,11 @@ export default function DelatArende() {
);
}
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");
// Hypoteser är internt arbetsmaterial och delas inte externt —
// de får aldrig riskera att läsas som konstaterade fel.
const kundposter = arende.handelser.filter(
(p) => p.handelse.typ !== "kategori_byte" && p.handelse.typ !== "hypotes",
);
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>
<DelatArendeVy
arende={arende}
nu={nu}
notis="Skrivskyddad livevy — uppdateras automatiskt när verkstaden registrerar ny information. Detta är förhandsvisningen; den externa delningslänken visar samma vy."
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
// Publik Live Share-sida: nås via delningskoden utan inloggning.
// Läser genom backend-funktionen hamta_delat_arende (security definer) —
// tabellerna exponeras aldrig, och interna poster är redan bortfiltrerade
// på serversidan. Pollar för liveuppdatering.
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import type { Arende, Handelse } from "@/felsokning/domain";
import { DelatArendeVy } from "@/felsokning/DelatArendeVy";
import { FelsokningSkal, Panel } from "@/felsokning/ui";
interface DelatSvar {
arende: { id: string; nummer: number; skapad: string };
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Handelse }[];
}
async function hamtaDelat(kod: string): Promise<Arende | undefined> {
const { supabase } = await import("@/integrations/supabase/client");
const { data, error } = await (supabase as unknown as {
rpc: (fn: string, args: object) => Promise<{ data: unknown; error: unknown }>;
}).rpc("hamta_delat_arende", { kod });
if (error || !data) return undefined;
const svar = data as DelatSvar;
return {
id: svar.arende.id,
nummer: svar.arende.nummer,
skapad: new Date(svar.arende.skapad).toISOString(),
handelser: svar.handelser.map((rad) => ({
id: rad.id,
tidpunkt: new Date(rad.tidpunkt).toISOString(),
anvandare: rad.anvandare,
handelse: rad.handelse,
})),
};
}
export default function PublikDelning() {
const { kod } = useParams<{ kod: string }>();
const [arende, setArende] = useState<Arende | undefined>();
const [status, setStatus] = useState<"laddar" | "klar" | "saknas">("laddar");
const [nu, setNu] = useState(() => new Date().toISOString());
useEffect(() => {
if (!kod) return;
let aktiv = true;
const hamta = async () => {
try {
const resultat = await hamtaDelat(kod);
if (!aktiv) return;
setArende(resultat);
setStatus(resultat ? "klar" : "saknas");
setNu(new Date().toISOString());
} catch {
if (aktiv) setStatus("saknas");
}
};
hamta();
const timer = setInterval(hamta, 20000);
return () => {
aktiv = false;
clearInterval(timer);
};
}, [kod]);
if (status !== "klar" || !arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<Panel>
<p className="text-lg text-zinc-300">
{status === "laddar"
? "Hämtar ärendet …"
: "Ärendet är inte tillgängligt. Kontrollera länken med verkstaden — delningen kan ha stängts av."}
</p>
</Panel>
</FelsokningSkal>
);
}
return (
<DelatArendeVy
arende={arende}
nu={nu}
notis="Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras."
/>
);
}