diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..c2a580a --- /dev/null +++ b/docs/DEMO.md @@ -0,0 +1,71 @@ +# Guidad Felsökning – Demomanus + +Ett 5–10 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å) | diff --git a/docs/MVP.md b/docs/MVP.md index 3103129..0937fe5 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 92dc160..6690108 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx new file mode 100644 index 0000000..921a1cf --- /dev/null +++ b/src/felsokning/DelatArendeVy.tsx @@ -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 ( + + {avslutat ? "Avslutat" : "🟢 Felsökning pågår"} + + } + > +

+ {notis} +

+ + {b.felbeskrivning && ( + +

”{b.felbeskrivning}”

+
+ )} + + + {b.utfordaKontroller.map((k, i) => ( +

✔ {k.text}

+ ))} + {!avslutat && pagaende &&

🔄 {pagaende}

} + {b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => ( +

⏳ {e}

+ ))} +
+ + {bilder.length > 0 && ( + +
+ {bilder.map((bild, i) => ( +
+ {bild.beskrivning} +
{bild.beskrivning}
+
+ ))} +
+
+ )} + + {matvarden.length > 0 && ( + + + + {matvarden.map((p) => { + const h = p.handelse; + if (h.typ !== "matvarde") return null; + return ( + + + + + ); + })} + +
{h.beskrivning} + {h.varde} + {h.enhet ? ` ${h.enhet}` : ""} +
+
+ )} + + + {kundposter.map((post) => ( +

+ {tidKlockslag(post.tidpunkt)}{" "} + {handelseRubrik(post)} +

+ ))} +
+ + {!avslutat && pagaende && ( + +

{pagaende}

+
+ )} + + +

{b.totalArbetstid}

+ {tidsfordelningsRader(arende, nu).map((r) => ( +

+ {r.label}: {r.tid} +

+ ))} +
+ +

+ Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg +

+
+ ); +} diff --git a/src/felsokning/__tests__/demo.test.ts b/src/felsokning/__tests__/demo.test.ts new file mode 100644 index 0000000..95e2042 --- /dev/null +++ b/src/felsokning/__tests__/demo.test.ts @@ -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(); + }); +}); diff --git a/src/felsokning/demo.ts b/src/felsokning/demo.ts new file mode 100644 index 0000000..a25eac0 --- /dev/null +++ b/src/felsokning/demo.ts @@ -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 85–95 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 85–95 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, + }; +} diff --git a/src/felsokning/store.ts b/src/felsokning/store.ts index 79ce977..200f76c 100644 --- a/src/felsokning/store.ts +++ b/src/felsokning/store.ts @@ -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()( }; }); }, + + laggInArende: (arende) => { + set((s) => ({ + arenden: { ...s.arenden, [arende.id]: arende }, + nastaNummer: Math.max(s.nastaNummer, arende.nummer + 1), + })); + }, }), { name: "guidad-felsokning" }, ), diff --git a/src/felsokning/ui.tsx b/src/felsokning/ui.tsx index f24db3d..028b4d3 100644 --- a/src/felsokning/ui.tsx +++ b/src/felsokning/ui.tsx @@ -20,8 +20,8 @@ export function FelsokningSkal({ children: ReactNode; }) { return ( -
-
+
+
{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} diff --git a/src/index.css b/src/index.css index 166ca4e..febb375 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } +} diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index d2c04ca..e45a9ab 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -648,6 +648,21 @@ function RapportFlik({ 🟢 Öppna Live Share-vy + {arende.delningskod && ( + { + navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`); + skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." }); + }} + > + 🔗 Kopiera delningslänk + + )} +

+ Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare). +

{b.objekt && ( diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index 10ab109..f03aa50 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -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("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 ( {anvandare}} > - + Nytt ärende + + Nytt ärende - {lista.length === 0 && ( -

- Inga ärenden ännu. Starta med att identifiera ett objekt. -

+ {alla.length > 0 && ( +
+ {FILTER.map((f) => ( + + ))} +
+ )} + + {alla.length === 0 && ( + +

+ Inga ärenden ännu. Starta med att identifiera ett objekt — eller utforska ett färdigt demoärende + med komplett arbetslogg, brief och kundrapport. +

+ laggInArende(byggDemoArende(nastaNummer, anvandare))}> + Skapa demoärende (Volvo XC60, vibration) + +
+ )} + + {lista.length === 0 && alla.length > 0 && ( +

Inga ärenden i det här filtret.

)} {lista.map((arende) => { diff --git a/src/pages/felsokning/DelatArende.tsx b/src/pages/felsokning/DelatArende.tsx index 128c1b1..44fb1f1 100644 --- a/src/pages/felsokning/DelatArende.tsx +++ b/src/pages/felsokning/DelatArende.tsx @@ -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 ( - - {avslutat ? "Avslutat" : "🟢 Felsökning pågår"} - - } - > -

- 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. -

- - {b.felbeskrivning && ( - -

”{b.felbeskrivning}”

-
- )} - - - {b.utfordaKontroller.map((k, i) => ( -

✔ {k.text}

- ))} - {!avslutat && pagaende &&

🔄 {pagaende}

} - {b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => ( -

⏳ {e}

- ))} -
- - {bilder.length > 0 && ( - -
- {bilder.map((bild, i) => ( -
- {bild.beskrivning} -
{bild.beskrivning}
-
- ))} -
-
- )} - - {matvarden.length > 0 && ( - - - - {matvarden.map((p) => { - const h = p.handelse; - if (h.typ !== "matvarde") return null; - return ( - - - - - ); - })} - -
{h.beskrivning} - {h.varde} - {h.enhet ? ` ${h.enhet}` : ""} -
-
- )} - - - {kundposter.map((post) => ( -

- {tidKlockslag(post.tidpunkt)}{" "} - {handelseRubrik(post)} -

- ))} -
- - {!avslutat && pagaende && ( - -

{pagaende}

-
- )} - - -

{b.totalArbetstid}

- {tidsfordelningsRader(arende, nu).map((r) => ( -

- {r.label}: {r.tid} -

- ))} -
- -

- Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg -

-
+ ); } diff --git a/src/pages/felsokning/PublikDelning.tsx b/src/pages/felsokning/PublikDelning.tsx new file mode 100644 index 0000000..7f1133d --- /dev/null +++ b/src/pages/felsokning/PublikDelning.tsx @@ -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 { + 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(); + 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 ( + + +

+ {status === "laddar" + ? "Hämtar ärendet …" + : "Ärendet är inte tillgängligt. Kontrollera länken med verkstaden — delningen kan ha stängts av."} +

+
+
+ ); + } + + return ( + + ); +}