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
+71
View File
@@ -0,0 +1,71 @@
# Guidad Felsökning Demomanus
Ett 510 minuters manus för att visa plattformen. Allt körs lokalt utan konton eller nycklar.
## Förberedelser
```sh
npm install
npm run dev
```
Öppna `http://localhost:8080/felsokning` — helst på mobil eller i mobilläge i webbläsaren (appen är byggd för verkstadsgolvet). Ange ett namn (allt loggas per användare).
Chrome rekommenderas: då fungerar även röstinmatningen (Push-to-Talk).
## Demoflöde
### 1. Dashboarden och demoärendet (1 min)
Klicka **Skapa demoärende (Volvo XC60, vibration)**. Ett komplett ärende med 1 tim 35 min arbetshistorik läggs in: identifierat objekt, besvarade symptomfrågor, fyra hjulfoton, mätvärden, provkörning, en överlämning mellan två tekniker och en hypotes.
Poäng att göra: dashboarden visar bara det viktigaste — pågående, klara, nytt ärende.
### 2. Ärendebriefen (2 min)
Öppna ärendet → fliken **Brief**. Det här är kärnargumentet:
- **Utförda kontroller** med resultat — inte bara kryssrutor.
- **Ej kontrollerat** — härlett automatiskt ur metodiken; det som orsakar dubbelarbete vid skiftbyte är det ingen skrivit ner att ingen gjort.
- **Hypoteser** tydligt märkta 🔴 — systemet presenterar aldrig en hypotes som ett konstaterat fel.
- **Tillförlitlighet** och **total arbetstid**.
Poäng: en ny tekniker är produktiv på under en minut, utan att läsa hundratals loggrader.
### 3. Guiden och verifierade checklistor (2 min)
Fliken **Guide**. Metodiken fortsätter där den slutade: en fråga eller kontroll i taget, stora knappar.
- Visa att en mätkontroll **inte kan verifieras utan mätvärde** (knappen är låst tills värdet är ifyllt).
- Tryck på 🎤 och diktera en observation — texten hamnar i fältet, redigerbar, och sparas först när man trycker Spara. Tal in, text ut; inget skickas automatiskt.
- Visa kategoriknapparna (aktiv felsökning / väntetid / provkörning …) — tidrapporteringen sköter sig själv.
### 4. Arbetsloggen (1 min)
Fliken **Logg**: varje händelse tidsstämplad med användare, append-only — ingenting kan ändras eller raderas i efterhand. Peka på överlämningen mellan Anna och Johan.
### 5. Kundrapporten och Live Share (2 min)
Fliken **Rapport**:
- **Skriv ut / PDF** — rapporten blir svart på vitt automatiskt.
- **Exportera JSON** — versionsmärkt (version = antal händelser), och exporten loggas själv.
- **Öppna Live Share-vy** — det kunden ser via delningslänken: status ✔/🔄/⏳, bilder, mätvärden, tidslinje. Inga hypoteser, inga interna poster.
Poäng: i stället för "Felsökning 2,5 timmar" på fakturan får kunden en tidslinje över vad som faktiskt gjorts.
### 6. Avsluta med filosofin (30 sek)
> Systemet dokumenterar observationer, leder användaren genom verifierbara kontroller och rekommenderar nästa steg — men presenterar aldrig en hypotes som ett konstaterat fel.
Det ersätter inte teknikern. Det ersätter pärmen, minneslapparna och "fråga Kent, han skruvade på den i torsdags".
## Vad som är demoläge respektive produktion
| I demon | I produktion |
| --- | --- |
| Deterministisk metodikmotor (3 metodiker) | LLM väljer/genererar steg genom samma motorgränssnitt |
| Webbläsarens taligenkänning | Leverantörens Voice-to-Text bakom samma gränssnitt |
| localStorage + synk vid inloggning | Multi-tenant-backend (migration finns), roller enligt Master Prompt |
| Delningslänk kräver synkat ärende | Live Share med behörighetsnivåer kund/intern/partner |
| Demobilder ritade av systemet | Riktiga foton via kameran (fungerar redan i demon också) |
+6 -2
View File
@@ -7,9 +7,11 @@ Första körbara versionen av kärnan i [Master Prompt v1.0](MASTER-PROMPT.md).
```sh
npm install
npm run dev # öppna http://localhost:8080/felsokning
npm test # projektionstester
npm test # projektions-, synk- och demotester
```
Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på startsidan lägger in ett komplett vibrationsärende med 1 tim 35 min historik.
## Vad som ingår
| Direktivets kärna | Status i MVP |
@@ -27,7 +29,9 @@ npm test # projektionstester
| Export | ✅ Versionsmärkt JSON-export (version = antal händelser vid exporttillfället, med användare och tidpunkt); exporten loggas själv som händelse. PDF via utskrift. CSV och API i backend-fasen. |
| Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. |
| Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. |
| Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Anonym extern åtkomst via delningskod är förberedd i databasen (security definer-funktion `hamta_delat_arende`); varje ärende får en slumpad delningskod vid skapande. Behörighetsnivåer (kund/intern/partner) i nästa fas. |
| Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. Behörighetsnivåer (kund/intern/partner) i nästa fas. |
| Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. |
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. |
| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON i Postgres). REST/OpenAPI-lager i nästa fas. |
## Arkitekturprinciper i koden
+2
View File
@@ -18,6 +18,7 @@ import Arendelista from "./pages/felsokning/Arendelista";
import NyttArende from "./pages/felsokning/NyttArende";
import ArendeSida from "./pages/felsokning/ArendeSida";
import DelatArende from "./pages/felsokning/DelatArende";
import PublikDelning from "./pages/felsokning/PublikDelning";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
@@ -41,6 +42,7 @@ function AppContent() {
<Route path="/felsokning/nytt" element={<NyttArende />} />
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
<Route path="/felsokning/dela/:id" element={<DelatArende />} />
<Route path="/felsokning/delad/:kod" element={<PublikDelning />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
+122
View File
@@ -0,0 +1,122 @@
// Gemensam Live Share-vy: används både av den interna förhandsvisningen
// (läser ur lokala storen) och den publika delningssidan (läser via
// delningskod genom backend). Skrivskyddad; interna poster (kategoribyten,
// hypoteser) ingår aldrig i underlaget som når hit externt, och filtreras
// bort även lokalt.
import type { Arende } from "./domain";
import { handelseRubrik } from "./domain";
import { arAvslutat, brief, foton, tidsfordelningsRader } from "./projektioner";
import { metodikForArende } from "./store";
import { tidDatum, tidKlockslag } from "./format";
import { FelsokningSkal, Panel } from "./ui";
export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: string; notis: string }) {
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" && 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 print:hidden">
{notis}
</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>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { byggDemoArende } from "../demo";
import { VIBRATION_METODIK, valjMetodik } from "../metodik";
import { brief, felbeskrivning } from "../projektioner";
describe("demoärendet", () => {
const arende = byggDemoArende(7);
it("väljer vibrationsmetodiken och har ett rikt underlag", () => {
expect(valjMetodik(felbeskrivning(arende)!).id).toBe("vibration");
const b = brief(arende, VIBRATION_METODIK);
expect(b.objekt?.identifierare).toBe("ABC123");
expect(b.utfordaKontroller.length).toBeGreaterThanOrEqual(10);
expect(b.observationer.length).toBeGreaterThanOrEqual(4);
expect(b.hypoteser).toHaveLength(1);
expect(b.avslutat).toBe(false);
});
it("lämnar kontroller kvar så att guiden kan fortsätta i demon", () => {
const b = brief(arende, VIBRATION_METODIK);
expect(b.ejKontrollerat).toContain("Kontrollera radial- och sidokast");
expect(b.ejKontrollerat).toContain("Förändring vid kurvtagning");
expect(b.rekommenderatNastaSteg.length).toBeGreaterThan(0);
});
it("har cirka 1,5 timmars loggad arbetstid och en överlämning", () => {
const b = brief(arende, VIBRATION_METODIK);
expect(b.totalArbetstid).toBe("1 tim 35 min");
expect(arende.handelser.some((p) => p.handelse.typ === "overlamning")).toBe(true);
expect(new Set(arende.handelser.map((p) => p.anvandare)).size).toBeGreaterThanOrEqual(2);
});
it("händelserna ligger i tidsordning med demoärendets id-format", () => {
const tider = arende.handelser.map((p) => p.tidpunkt);
expect([...tider].sort()).toEqual(tider);
expect(arende.id).toMatch(/^arende-demo-/);
expect(arende.delningskod).toBeTruthy();
});
});
+110
View File
@@ -0,0 +1,110 @@
// Demoärende: vibrationsexemplet ur visionen, komplett med besvarade
// symptomfrågor, verifierade kontroller, mätvärden, foton, kategoribyten
// och en hypotes — så att brief, tidrapport, kundrapport och Live Share
// har verkligt innehåll direkt.
import type { Arende, Handelse, LoggPost } from "./domain";
import { nyLoggPost } from "./domain";
// Enkla platshållarbilder ritade i canvas — ersätts av riktiga foton så
// fort en kamera används. Fallback (1×1) när canvas saknas (t.ex. tester).
function demoFoto(text: string): string {
try {
const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 360;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas saknas");
ctx.fillStyle = "#27272a";
ctx.fillRect(0, 0, 480, 360);
ctx.strokeStyle = "#52525b";
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(240, 170, 110, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#a1a1aa";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(240, 170, 58, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 5; i++) {
const v = (i * 2 * Math.PI) / 5 - Math.PI / 2;
ctx.beginPath();
ctx.moveTo(240, 170);
ctx.lineTo(240 + Math.cos(v) * 58, 170 + Math.sin(v) * 58);
ctx.stroke();
}
ctx.fillStyle = "#fbbf24";
ctx.font = "bold 22px sans-serif";
ctx.textAlign = "center";
ctx.fillText(text, 240, 330);
ctx.fillStyle = "#71717a";
ctx.font = "14px sans-serif";
ctx.fillText("Demobild", 240, 26);
return canvas.toDataURL("image/jpeg", 0.7);
} catch {
return "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
}
}
export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
const start = Date.now() - 97 * 60000; // 1 tim 37 min sedan
const vid = (minuter: number) => new Date(start + minuter * 60000).toISOString();
const post = (minuter: number, handelse: Handelse, vem = anvandare): LoggPost =>
nyLoggPost(vem, handelse, vid(minuter));
const handelser: LoggPost[] = [
post(0, {
typ: "objekt_identifierat",
objekt: {
typ: "Fordon",
identifierare: "ABC123",
identifieringsmetod: "Registreringsnummer",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
},
}),
post(2, { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h. Symptomet uppträder endast under körning." }),
post(4, { typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "Är vibrationen hastighetsberoende?", svar: "Ja" }),
post(5, { typ: "fraga_besvarad", stegId: "symptom", frageId: "var", fraga: "Var känns vibrationen?", svar: "I ratten" }),
post(6, { typ: "fraga_besvarad", stegId: "symptom", frageId: "nar", fraga: "När uppstår vibrationen?", svar: "Vid jämn fart" }),
post(8, { typ: "fraga_besvarad", stegId: "symptom", frageId: "intervall", fraga: "Försvinner den över eller under ett visst hastighetsintervall?", svar: "Märks tydligast 8595 km/h, försvinner under 80" }),
post(12, { typ: "foto", beskrivning: "Fotografera vänster framhjul", dataUrl: demoFoto("Vänster framhjul") }),
post(12, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_vf", text: "Fotografera vänster framhjul" }),
post(14, { typ: "foto", beskrivning: "Fotografera höger framhjul", dataUrl: demoFoto("Höger framhjul") }),
post(14, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_hf", text: "Fotografera höger framhjul" }),
post(16, { typ: "foto", beskrivning: "Fotografera vänster bakhjul", dataUrl: demoFoto("Vänster bakhjul") }),
post(16, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_vb", text: "Fotografera vänster bakhjul" }),
post(18, { typ: "foto", beskrivning: "Fotografera höger bakhjul", dataUrl: demoFoto("Höger bakhjul") }),
post(18, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_hb", text: "Fotografera höger bakhjul" }),
post(22, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "dot", text: "Dokumentera DOT-/tillverkningsdatum", resultat: "Fram: DOT 2318, bak: DOT 2318 — samtliga från vecka 23 2018" }),
post(26, { typ: "observation", text: "Höger framdäck visar ojämnt slitage på innerkanten" }),
post(26, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "slitage", text: "Kontrollera däckslitage", resultat: "Ojämnt slitage höger fram, övriga ok" }),
post(31, { typ: "matvarde", beskrivning: "Lufttryck vänster fram", varde: "2,4", enhet: "bar" }),
post(32, { typ: "matvarde", beskrivning: "Lufttryck höger fram", varde: "2,1", enhet: "bar" }),
post(33, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "VF 2,4 / HF 2,1 / VB 2,4 / HB 2,4 bar — justerat till 2,4 runtom" }),
post(38, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "hjulmoment", text: "Kontrollera hjulmoment", resultat: "140 Nm samtliga hjul, ok" }),
post(40, { typ: "hypotes", text: "Obalans eller kast i höger framhjul", niva: "lag" }),
post(41, { typ: "kategori_byte", kategori: "provkorning" }),
post(55, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_hastighet", text: "Hastighet där vibration uppstår", resultat: "Tydlig vibration 8595 km/h, max vid 88" }),
post(56, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_acc", text: "Förändring vid acceleration", resultat: "Ingen förändring vid acceleration" }),
post(57, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_motorbroms", text: "Förändring vid motorbroms", resultat: "Ingen förändring vid motorbroms" }),
post(58, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_var", text: "Om vibration känns i ratt eller kaross", resultat: "Endast i ratten" }),
post(60, { typ: "kategori_byte", kategori: "aktiv_felsokning" }),
post(75, { typ: "inaktivitet_forklarad", text: "Lyfte bilen och demonterade höger framhjul för kontroll i balanseringsmaskin.", minuter: 15 }),
post(82, { typ: "matvarde", beskrivning: "Obalans höger framhjul", varde: "38", enhet: "g" }),
post(83, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "balansering", text: "Kontrollera hjulbalansering", resultat: "38 g obalans höger fram — en balansvikt saknas. Övriga hjul inom 5 g." }),
post(85, { typ: "observation", text: "Spår efter lossnad klistervikt på höger framfälgs insida" }),
post(90, { typ: "overlamning", fran: anvandare, till: "Johan" }),
post(92, { typ: "kategori_byte", kategori: "administration" }, "Johan"),
post(95, { typ: "kommentar", text: "Balanserat om höger framhjul, ny provkörning planerad." }, "Johan"),
];
return {
id: `arende-demo-${start.toString(36)}`,
nummer,
skapad: vid(0),
delningskod: `demo-${start.toString(36)}`,
handelser,
};
}
+9
View File
@@ -20,6 +20,8 @@ interface FelsokningState {
// Används endast av synken: ersätter listan med den ihopflätade versionen.
// Semantiken är fortfarande append-only — flätningen lägger bara till.
sammanfoga: (arendeId: string, handelser: LoggPost[]) => void;
// Lägger in ett färdigbyggt ärende (demoärende, importerat ärende).
laggInArende: (arende: Arende) => void;
}
function nyDelningskod(): string {
@@ -83,6 +85,13 @@ export const useFelsokning = create<FelsokningState>()(
};
});
},
laggInArende: (arende) => {
set((s) => ({
arenden: { ...s.arenden, [arende.id]: arende },
nastaNummer: Math.max(s.nastaNummer, arende.nummer + 1),
}));
},
}),
{ name: "guidad-felsokning" },
),
+3 -3
View File
@@ -20,8 +20,8 @@ export function FelsokningSkal({
children: ReactNode;
}) {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-50">
<header className="sticky top-0 z-20 border-b-2 border-zinc-700 bg-zinc-900 px-4 py-3">
<div className="felsokning-print min-h-screen bg-zinc-950 text-zinc-50">
<header className="sticky top-0 z-20 border-b-2 border-zinc-700 bg-zinc-900 px-4 py-3 print:static">
<div className="mx-auto flex max-w-3xl items-center justify-between gap-3">
<div className="min-w-0">
{tillbaka && (
@@ -65,7 +65,7 @@ export function StorKnapp({
type={type}
onClick={onClick}
disabled={disabled}
className={`min-h-14 w-full rounded-lg px-5 py-3 text-lg font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${farg} ${className}`}
className={`min-h-14 w-full rounded-lg px-5 py-3 text-lg font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 print:hidden ${farg} ${className}`}
>
{children}
</button>
+18
View File
@@ -219,3 +219,21 @@
cursor: grab;
}
}
/* Guidad Felsökning: utskriftsvänlig kundrapport — mörkt arbetsläge på
skärm, svart på vitt på papper. Interaktiva element döljs vid utskrift. */
@media print {
.felsokning-print,
.felsokning-print * {
background: #fff !important;
color: #111 !important;
border-color: #d4d4d8 !important;
animation: none !important;
}
.felsokning-print nav,
.felsokning-print button,
.felsokning-print input,
.felsokning-print textarea {
display: none !important;
}
}
+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."
/>
);
}