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:
@@ -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å) |
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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