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