Bygg MVP av Guidad Felsökning under /felsokning
Körbar kärna enligt Master Prompt v1.0: - Append-only händelselogg som enda sanningskälla (domain.ts); brief, tidsfördelning, överlämningstext och kundrapport är rena projektioner av loggen (projektioner.ts) med tester som låser att briefen alltid kan regenereras. - Deterministisk metodikmotor (metodik.ts): en fråga i taget, vibrationsmetodik + generisk metodik vald utifrån felbeskrivningen. Framtida AI-integration ansluter i nastaSteg utan modelländringar. - Flöde: identifiera objekt → bekräfta → felbeskrivning → guidad felsökning med foto, mätvärden, observationer och hypoteser (hypoteser märks alltid som ej verifierade). - Tidredovisning med kategorier, inaktivitetsfråga efter 20 min, överlämning med ett klick, kundrapport med tidslinje och utskrift. - Fabriksverktygs-UI: hög kontrast, stora knappar, få val per skärm. - Lagring i localStorage i MVP:t; docs/MVP.md beskriver omfattning och medvetna avgränsningar. Verifierat: 11 vitest-tester gröna, produktionsbygge ok, Playwright- röktest genom hela flödet utan sidfel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Guidad Felsökning – MVP
|
||||||
|
|
||||||
|
Första körbara versionen av kärnan i [Master Prompt v1.0](MASTER-PROMPT.md). Byggd som en fristående del av denna kodbas under `/felsokning`.
|
||||||
|
|
||||||
|
## Kör
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev # öppna http://localhost:8080/felsokning
|
||||||
|
npm test # projektionstester
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vad som ingår
|
||||||
|
|
||||||
|
| Direktivets kärna | Status i MVP |
|
||||||
|
| --- | --- |
|
||||||
|
| Objektidentifiering först | ✅ Manuell inmatning (reg.nr/VIN/serienummer/maskinnummer) med bekräftelsesteg. QR/streckkod/OCR är markerade som kommande. |
|
||||||
|
| AI-guidad felsökning | ✅ Deterministisk metodikmotor: en fråga i taget, stora knappar. Två metodiker (vibration + generisk) som väljs utifrån felbeskrivningen. LLM-integrationen ansluter i samma motor senare. |
|
||||||
|
| Arbetslogg | ✅ Append-only händelselogg med tidsstämpel och användare på varje post. Ingenting skrivs över. |
|
||||||
|
| Tidredovisning | ✅ Kategorier (aktiv felsökning, väntetid, provkörning …) via kategoribyten i loggen; paus räknas inte i total tid. Inaktivitetsfråga efter 20 min utan händelser. |
|
||||||
|
| Dokumentation | ✅ Observationer, mätvärden, foton (nedskalade), kommentarer och hypoteser. Hypoteser märks alltid 🔴 och kan aldrig loggas som konstaterade fel. |
|
||||||
|
| Ärendebrief | ✅ Regenereras ur loggen vid varje visning: utförda kontroller, observationer, **ej kontrollerat**, rekommenderat nästa steg, tillförlitlighet, total arbetstid. |
|
||||||
|
| Överlämning | ✅ ”Lämna över arbete” genererar överlämningsrapport ur briefen och loggar överlämningen. |
|
||||||
|
| Kundrapport | ✅ Tidslinjevy utan interna poster, med bilder och tidsfördelning. Utskrift/PDF via webbläsaren, med påminnelse om granskning före delning. |
|
||||||
|
| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON), men MVP:t lagrar lokalt (localStorage). Nästa steg: backend med samma händelsemodell. |
|
||||||
|
|
||||||
|
## Arkitekturprinciper i koden
|
||||||
|
|
||||||
|
- **Händelseloggen är enda sanningskällan.** `src/felsokning/domain.ts` definierar händelsetyperna; poster läggs endast till.
|
||||||
|
- **Alla vyer är projektioner.** `src/felsokning/projektioner.ts` — brief, tidsfördelning, överlämningstext och kundrapport är rena funktioner av loggen och kan alltid regenereras. Testerna i `src/felsokning/__tests__/` låser detta.
|
||||||
|
- **Metodikmotorn är deterministisk.** `src/felsokning/metodik.ts` — nästa steg härleds ur vad som redan dokumenterats. Det är här den framtida AI:n ansluter, utan att logg eller projektioner ändras.
|
||||||
|
- **Fabriksverktygs-UI.** `src/felsokning/ui.tsx` — hög kontrast, minst 56 px höga knappar, få val per skärm.
|
||||||
|
|
||||||
|
## Medvetna avgränsningar
|
||||||
|
|
||||||
|
- Ingen backend/multi-tenant ännu — datat bor per enhet. Append-only-modellen är vald just för att synkronisering (inkl. offline) blir konfliktfri när backend läggs till.
|
||||||
|
- Ingen LLM-koppling — guidningen är regelstyrd metodik. Gränssnittet mot motorn (`nastaSteg`) är den framtida integrationspunkten.
|
||||||
|
- Röst, bildanalys och tillverkarintegrationer ingår inte i MVP:t.
|
||||||
@@ -14,6 +14,9 @@ import Admin from "./pages/Admin";
|
|||||||
import FAQ from "./pages/FAQ";
|
import FAQ from "./pages/FAQ";
|
||||||
import OrderConfirmation from "./pages/OrderConfirmation";
|
import OrderConfirmation from "./pages/OrderConfirmation";
|
||||||
import ChangeSubscription from "./pages/ChangeSubscription";
|
import ChangeSubscription from "./pages/ChangeSubscription";
|
||||||
|
import Arendelista from "./pages/felsokning/Arendelista";
|
||||||
|
import NyttArende from "./pages/felsokning/NyttArende";
|
||||||
|
import ArendeSida from "./pages/felsokning/ArendeSida";
|
||||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -33,6 +36,9 @@ function AppContent() {
|
|||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/order-confirmation" element={<OrderConfirmation />} />
|
<Route path="/order-confirmation" element={<OrderConfirmation />} />
|
||||||
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
|
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
|
||||||
|
<Route path="/felsokning" element={<Arendelista />} />
|
||||||
|
<Route path="/felsokning/nytt" element={<NyttArende />} />
|
||||||
|
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
|
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
|
||||||
|
|
||||||
export function PaymentTestModeBanner() {
|
export function PaymentTestModeBanner() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
// Guidad Felsökning har inga betalningar — bannern gäller bara butiken.
|
||||||
|
if (pathname.startsWith("/felsokning")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!clientToken) {
|
if (!clientToken) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
|
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Arende, Handelse } from "../domain";
|
||||||
|
import { nyLoggPost } from "../domain";
|
||||||
|
import { VIBRATION_METODIK, nastaSteg, valjMetodik } from "../metodik";
|
||||||
|
import { brief, ejKontrollerat, tidsfordelning } from "../projektioner";
|
||||||
|
|
||||||
|
// Hjälpare: bygger ett ärende ur en lista (tidpunkt, händelse) —
|
||||||
|
// precis som produktionskoden är alla vyer rena funktioner av loggen.
|
||||||
|
function byggArende(poster: [string, Handelse][]): Arende {
|
||||||
|
return {
|
||||||
|
id: "test",
|
||||||
|
nummer: 1,
|
||||||
|
skapad: poster[0]?.[0] ?? "2026-08-02T08:00:00Z",
|
||||||
|
handelser: poster.map(([tidpunkt, handelse]) => nyLoggPost("Anna", handelse, tidpunkt)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OBJEKT: Handelse = {
|
||||||
|
typ: "objekt_identifierat",
|
||||||
|
objekt: {
|
||||||
|
typ: "Fordon",
|
||||||
|
identifierare: "ABC123",
|
||||||
|
identifieringsmetod: "Registreringsnummer",
|
||||||
|
beskrivning: "Volvo XC60 D4 2019",
|
||||||
|
kund: "Anders Svensson",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("metodikval", () => {
|
||||||
|
it("väljer vibrationsmetodiken när felbeskrivningen nämner vibration", () => {
|
||||||
|
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
|
||||||
|
expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nastaSteg", () => {
|
||||||
|
it("börjar med första symptomfrågan och går vidare i metodikens ordning", () => {
|
||||||
|
const arende = byggArende([
|
||||||
|
["2026-08-02T08:03:00Z", OBJEKT],
|
||||||
|
["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
|
||||||
|
]);
|
||||||
|
const steg = nastaSteg(arende, VIBRATION_METODIK);
|
||||||
|
expect(steg.klart).toBe(false);
|
||||||
|
expect(steg.steg.id).toBe("symptom");
|
||||||
|
expect(steg.fraga?.id).toBe("hastighetsberoende");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hoppar till nästa obesvarade fråga när en besvarats", () => {
|
||||||
|
const arende = byggArende([
|
||||||
|
["2026-08-02T08:03:00Z", OBJEKT],
|
||||||
|
[
|
||||||
|
"2026-08-02T08:06:00Z",
|
||||||
|
{ typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "…", svar: "Ja" },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
expect(nastaSteg(arende, VIBRATION_METODIK).fraga?.id).toBe("var");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("är klart när alla frågor och kontroller är dokumenterade", () => {
|
||||||
|
const poster: [string, Handelse][] = [["2026-08-02T08:00:00Z", OBJEKT]];
|
||||||
|
for (const steg of VIBRATION_METODIK.steg) {
|
||||||
|
for (const fraga of steg.fragor ?? []) {
|
||||||
|
poster.push([
|
||||||
|
"2026-08-02T08:10:00Z",
|
||||||
|
{ typ: "fraga_besvarad", stegId: steg.id, frageId: fraga.id, fraga: fraga.text, svar: "Ja" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const kontroll of steg.kontroller ?? []) {
|
||||||
|
poster.push([
|
||||||
|
"2026-08-02T08:20:00Z",
|
||||||
|
{ typ: "kontroll_utford", stegId: steg.id, kontrollId: kontroll.id, text: kontroll.text },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(nastaSteg(byggArende(poster), VIBRATION_METODIK).klart).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ejKontrollerat", () => {
|
||||||
|
it("krymper när kontroller utförs och härleds helt ur loggen", () => {
|
||||||
|
const utan = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
|
||||||
|
const alla = ejKontrollerat(utan, VIBRATION_METODIK);
|
||||||
|
expect(alla).toContain("Kontrollera lufttryck");
|
||||||
|
|
||||||
|
const med = byggArende([
|
||||||
|
["2026-08-02T08:00:00Z", OBJEKT],
|
||||||
|
[
|
||||||
|
"2026-08-02T08:15:00Z",
|
||||||
|
{ typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck" },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const kvar = ejKontrollerat(med, VIBRATION_METODIK);
|
||||||
|
expect(kvar).not.toContain("Kontrollera lufttryck");
|
||||||
|
expect(kvar.length).toBe(alla.length - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tidsfordelning", () => {
|
||||||
|
it("attribuerar intervall till aktiv kategori och räknar inte paus i totalen", () => {
|
||||||
|
const arende = byggArende([
|
||||||
|
["2026-08-02T08:00:00Z", OBJEKT],
|
||||||
|
// 30 min aktiv felsökning (standardkategori)
|
||||||
|
["2026-08-02T08:30:00Z", { typ: "kategori_byte", kategori: "paus" }],
|
||||||
|
// 15 min paus
|
||||||
|
["2026-08-02T08:45:00Z", { typ: "kategori_byte", kategori: "provkorning" }],
|
||||||
|
// 20 min provkörning
|
||||||
|
["2026-08-02T09:05:00Z", { typ: "arende_avslutat" }],
|
||||||
|
]);
|
||||||
|
const { totalMs, perKategori } = tidsfordelning(arende);
|
||||||
|
expect(perKategori.aktiv_felsokning).toBe(30 * 60000);
|
||||||
|
expect(perKategori.paus).toBe(15 * 60000);
|
||||||
|
expect(perKategori.provkorning).toBe(20 * 60000);
|
||||||
|
expect(totalMs).toBe(50 * 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("räknar pågående tid fram till 'nu' för öppna ärenden", () => {
|
||||||
|
const arende = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
|
||||||
|
const { totalMs } = tidsfordelning(arende, "2026-08-02T08:10:00Z");
|
||||||
|
expect(totalMs).toBe(10 * 60000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("brief", () => {
|
||||||
|
const arende = byggArende([
|
||||||
|
["2026-08-02T08:03:00Z", OBJEKT],
|
||||||
|
["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
|
||||||
|
[
|
||||||
|
"2026-08-02T08:11:00Z",
|
||||||
|
{ typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "2,4 bar" },
|
||||||
|
],
|
||||||
|
["2026-08-02T08:18:00Z", { typ: "matvarde", beskrivning: "Matningsspänning", varde: "13,9", enhet: "V" }],
|
||||||
|
["2026-08-02T08:20:00Z", { typ: "observation", text: "Höger framdäck visar ojämnt slitage" }],
|
||||||
|
["2026-08-02T08:22:00Z", { typ: "hypotes", text: "Obalans i höger framhjul", niva: "lag" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("sammanställer utfört, observerat, ej kontrollerat och nästa steg ur loggen", () => {
|
||||||
|
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
|
||||||
|
expect(b.objekt?.identifierare).toBe("ABC123");
|
||||||
|
expect(b.felbeskrivning).toBe("Bilen vibrerar runt 88 km/h");
|
||||||
|
expect(b.utfordaKontroller.map((k) => k.text)).toContain("Kontrollera lufttryck");
|
||||||
|
expect(b.observationer).toContain("Höger framdäck visar ojämnt slitage");
|
||||||
|
expect(b.observationer).toContain("Matningsspänning: 13,9 V");
|
||||||
|
expect(b.ejKontrollerat).toContain("Kontrollera hjulbalansering");
|
||||||
|
expect(b.ejKontrollerat).not.toContain("Kontrollera lufttryck");
|
||||||
|
expect(b.rekommenderatNastaSteg[0]).toContain("hastighetsberoende");
|
||||||
|
expect(b.totalArbetstid).toBe("27 min");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skiljer hypoteser från fakta och markerar felorsak som ej verifierad", () => {
|
||||||
|
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
|
||||||
|
expect(b.hypoteser).toEqual([{ text: "Obalans i höger framhjul", niva: "lag" }]);
|
||||||
|
expect(b.observationer).not.toContain("Obalans i höger framhjul");
|
||||||
|
expect(b.tillforlitlighet.some((r) => r.niva === "medel" && /inte verifierad/.test(r.text))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("är regenererbar: samma logg ger alltid samma brief", () => {
|
||||||
|
const b1 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
|
||||||
|
const b2 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
|
||||||
|
expect(b2).toEqual(b1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Domänmodell för Guidad Felsökning.
|
||||||
|
// Händelseloggen är append-only och är ärendets enda sanningskälla.
|
||||||
|
// Allt annat (brief, tidrapport, kundrapport) är projektioner av loggen.
|
||||||
|
|
||||||
|
export type Tillforlitlighet = "hog" | "medel" | "lag";
|
||||||
|
|
||||||
|
export const TILLFORLITLIGHET_LABEL: Record<Tillforlitlighet, string> = {
|
||||||
|
hog: "🟢 Hög",
|
||||||
|
medel: "🟡 Medel",
|
||||||
|
lag: "🔴 Låg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TidKategori =
|
||||||
|
| "aktiv_felsokning"
|
||||||
|
| "vantetid"
|
||||||
|
| "administration"
|
||||||
|
| "reservdelssokning"
|
||||||
|
| "provkorning"
|
||||||
|
| "kundkontakt"
|
||||||
|
| "paus";
|
||||||
|
|
||||||
|
export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
|
||||||
|
aktiv_felsokning: "Aktiv felsökning",
|
||||||
|
vantetid: "Väntetid",
|
||||||
|
administration: "Administration",
|
||||||
|
reservdelssokning: "Reservdelssökning",
|
||||||
|
provkorning: "Provkörning",
|
||||||
|
kundkontakt: "Kundkontakt",
|
||||||
|
paus: "Paus",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Objekt {
|
||||||
|
typ: string; // t.ex. "Fordon", "Industrimaskin"
|
||||||
|
identifierare: string; // reg.nr, VIN, serienummer …
|
||||||
|
identifieringsmetod: string; // "Registreringsnummer", "VIN", "Serienummer", "Manuell inmatning"
|
||||||
|
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
|
||||||
|
kund?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
|
||||||
|
export type Handelse =
|
||||||
|
| { typ: "objekt_identifierat"; objekt: Objekt }
|
||||||
|
| { typ: "felbeskrivning"; text: string }
|
||||||
|
| { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
|
||||||
|
| { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string }
|
||||||
|
| { typ: "observation"; text: string }
|
||||||
|
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
|
||||||
|
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
|
||||||
|
| { typ: "foto"; beskrivning: string; dataUrl: string }
|
||||||
|
| { typ: "kommentar"; text: string }
|
||||||
|
| { typ: "kategori_byte"; kategori: TidKategori }
|
||||||
|
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
||||||
|
| { typ: "overlamning"; fran: string; till?: string }
|
||||||
|
| { typ: "arende_avslutat" };
|
||||||
|
|
||||||
|
export interface LoggPost {
|
||||||
|
id: string;
|
||||||
|
tidpunkt: string; // ISO 8601
|
||||||
|
anvandare: string;
|
||||||
|
handelse: Handelse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Arende {
|
||||||
|
id: string;
|
||||||
|
nummer: number;
|
||||||
|
skapad: string; // ISO 8601
|
||||||
|
handelser: LoggPost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let raknare = 0;
|
||||||
|
|
||||||
|
export function nyLoggPost(anvandare: string, handelse: Handelse, tidpunkt?: string): LoggPost {
|
||||||
|
raknare += 1;
|
||||||
|
return {
|
||||||
|
id: `${Date.now().toString(36)}-${raknare.toString(36)}`,
|
||||||
|
tidpunkt: tidpunkt ?? new Date().toISOString(),
|
||||||
|
anvandare,
|
||||||
|
handelse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handelseRubrik(post: LoggPost): string {
|
||||||
|
const h = post.handelse;
|
||||||
|
switch (h.typ) {
|
||||||
|
case "objekt_identifierat":
|
||||||
|
return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`;
|
||||||
|
case "felbeskrivning":
|
||||||
|
return `Felbeskrivning registrerad`;
|
||||||
|
case "fraga_besvarad":
|
||||||
|
return `${h.fraga} — ${h.svar}`;
|
||||||
|
case "kontroll_utford":
|
||||||
|
return h.resultat ? `${h.text} — ${h.resultat}` : `${h.text} — utförd`;
|
||||||
|
case "observation":
|
||||||
|
return `Observation: ${h.text}`;
|
||||||
|
case "matvarde":
|
||||||
|
return `Mätvärde: ${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`;
|
||||||
|
case "hypotes":
|
||||||
|
return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`;
|
||||||
|
case "foto":
|
||||||
|
return `Foto: ${h.beskrivning}`;
|
||||||
|
case "kommentar":
|
||||||
|
return h.text;
|
||||||
|
case "kategori_byte":
|
||||||
|
return `Arbetskategori: ${TIDKATEGORI_LABEL[h.kategori]}`;
|
||||||
|
case "inaktivitet_forklarad":
|
||||||
|
return `Komplettering (${h.minuter} min utan aktivitet): ${h.text}`;
|
||||||
|
case "overlamning":
|
||||||
|
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
|
||||||
|
case "arende_avslutat":
|
||||||
|
return "Felsökning avslutad";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Formatering och bildhantering — utan React-beroenden.
|
||||||
|
|
||||||
|
export function tidKlockslag(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString("sv-SE", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tidDatum(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("sv-SE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skalar ner ett foto innan det loggas, så att händelseloggen förblir hanterbar.
|
||||||
|
export function skalaNerFoto(fil: File, maxSida = 1024): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const lasare = new FileReader();
|
||||||
|
lasare.onerror = () => reject(new Error("Kunde inte läsa filen"));
|
||||||
|
lasare.onload = () => {
|
||||||
|
const bild = new Image();
|
||||||
|
bild.onerror = () => reject(new Error("Kunde inte tolka bilden"));
|
||||||
|
bild.onload = () => {
|
||||||
|
const skala = Math.min(1, maxSida / Math.max(bild.width, bild.height));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.round(bild.width * skala);
|
||||||
|
canvas.height = Math.round(bild.height * skala);
|
||||||
|
canvas.getContext("2d")!.drawImage(bild, 0, 0, canvas.width, canvas.height);
|
||||||
|
resolve(canvas.toDataURL("image/jpeg", 0.7));
|
||||||
|
};
|
||||||
|
bild.src = lasare.result as string;
|
||||||
|
};
|
||||||
|
lasare.readAsDataURL(fil);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Metodikmotor: deterministiska felsökningsflöden.
|
||||||
|
// Systemet presenterar aldrig en hypotes som ett konstaterat fel —
|
||||||
|
// motorn föreslår nästa verifierbara steg utifrån vad som redan är dokumenterat.
|
||||||
|
//
|
||||||
|
// I MVP:t är metodikerna fördefinierade flöden. Den framtida AI-integrationen
|
||||||
|
// ansluter här: den väljer/genererar steg, men händelseloggen och
|
||||||
|
// projektionerna är oförändrade.
|
||||||
|
|
||||||
|
import type { Arende } from "./domain";
|
||||||
|
|
||||||
|
export interface Fraga {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
svarstyp: "janej" | "text" | "val";
|
||||||
|
val?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Kontroll {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetodikSteg {
|
||||||
|
id: string;
|
||||||
|
rubrik: string;
|
||||||
|
beskrivning?: string;
|
||||||
|
fragor?: Fraga[];
|
||||||
|
kontroller?: Kontroll[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Metodik {
|
||||||
|
id: string;
|
||||||
|
namn: string;
|
||||||
|
steg: MetodikSteg[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VIBRATION_METODIK: Metodik = {
|
||||||
|
id: "vibration",
|
||||||
|
namn: "Vibration under körning",
|
||||||
|
steg: [
|
||||||
|
{
|
||||||
|
id: "symptom",
|
||||||
|
rubrik: "Verifiera symptom",
|
||||||
|
fragor: [
|
||||||
|
{ id: "hastighetsberoende", text: "Är vibrationen hastighetsberoende?", svarstyp: "janej" },
|
||||||
|
{
|
||||||
|
id: "var",
|
||||||
|
text: "Var känns vibrationen?",
|
||||||
|
svarstyp: "val",
|
||||||
|
val: ["I ratten", "I stolen", "I hela bilen"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nar",
|
||||||
|
text: "När uppstår vibrationen?",
|
||||||
|
svarstyp: "val",
|
||||||
|
val: ["Vid acceleration", "Vid jämn fart", "Vid inbromsning", "Alltid under körning"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intervall",
|
||||||
|
text: "Försvinner den över eller under ett visst hastighetsintervall?",
|
||||||
|
svarstyp: "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "visuell",
|
||||||
|
rubrik: "Visuell kontroll",
|
||||||
|
beskrivning: "Fotografera samtliga hjul. Skilj på observation och slutsats.",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "foto_vf", text: "Fotografera vänster framhjul" },
|
||||||
|
{ id: "foto_hf", text: "Fotografera höger framhjul" },
|
||||||
|
{ id: "foto_vb", text: "Fotografera vänster bakhjul" },
|
||||||
|
{ id: "foto_hb", text: "Fotografera höger bakhjul" },
|
||||||
|
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum" },
|
||||||
|
{ id: "slitage", text: "Kontrollera däckslitage" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kontroller",
|
||||||
|
rubrik: "Rekommenderade kontroller",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "lufttryck", text: "Kontrollera lufttryck" },
|
||||||
|
{ id: "hjulmoment", text: "Kontrollera hjulmoment" },
|
||||||
|
{ id: "kast", text: "Kontrollera radial- och sidokast" },
|
||||||
|
{ id: "balansering", text: "Kontrollera hjulbalansering" },
|
||||||
|
{ id: "bussningar", text: "Kontrollera bussningar och leder" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "provkorning",
|
||||||
|
rubrik: "Provkörning",
|
||||||
|
beskrivning: "Verifiera under provkörning:",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår" },
|
||||||
|
{ id: "pk_acc", text: "Förändring vid acceleration" },
|
||||||
|
{ id: "pk_motorbroms", text: "Förändring vid motorbroms" },
|
||||||
|
{ id: "pk_kurva", text: "Förändring vid kurvtagning" },
|
||||||
|
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GENERISK_METODIK: Metodik = {
|
||||||
|
id: "generisk",
|
||||||
|
namn: "Generell strukturerad felsökning",
|
||||||
|
steg: [
|
||||||
|
{
|
||||||
|
id: "symptom",
|
||||||
|
rubrik: "Verifiera symptom",
|
||||||
|
fragor: [
|
||||||
|
{ id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
|
||||||
|
{
|
||||||
|
id: "nar",
|
||||||
|
text: "När uppträder felet?",
|
||||||
|
svarstyp: "val",
|
||||||
|
val: ["Alltid", "Ibland", "Endast under drift", "Endast vid start"],
|
||||||
|
},
|
||||||
|
{ id: "forlopp", text: "Beskriv förloppet när felet uppstår", svarstyp: "text" },
|
||||||
|
{ id: "forandring", text: "Har något förändrats nyligen (service, reparation, miljö)?", svarstyp: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "visuell",
|
||||||
|
rubrik: "Visuell kontroll",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området" },
|
||||||
|
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar" },
|
||||||
|
{ id: "typskylt", text: "Dokumentera typskylt/märkning" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grundkontroller",
|
||||||
|
rubrik: "Grundkontroller",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)" },
|
||||||
|
{ id: "sakringar", text: "Kontrollera säkringar/skydd" },
|
||||||
|
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "funktionstest",
|
||||||
|
rubrik: "Funktionstest",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const METODIKER = [VIBRATION_METODIK, GENERISK_METODIK];
|
||||||
|
|
||||||
|
export function metodikForId(id: string): Metodik {
|
||||||
|
return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function valjMetodik(felbeskrivning: string): Metodik {
|
||||||
|
const text = felbeskrivning.toLowerCase();
|
||||||
|
if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
|
||||||
|
return GENERISK_METODIK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NastaSteg {
|
||||||
|
steg: MetodikSteg;
|
||||||
|
fraga?: Fraga;
|
||||||
|
kontroll?: Kontroll;
|
||||||
|
klart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nästa steg = första obesvarade frågan, därefter första ej utförda kontrollen,
|
||||||
|
// i metodikens ordning. Helt härlett ur händelseloggen.
|
||||||
|
export function nastaSteg(arende: Arende, metodik: Metodik): NastaSteg {
|
||||||
|
const besvarade = new Set<string>();
|
||||||
|
const utforda = new Set<string>();
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
const h = post.handelse;
|
||||||
|
if (h.typ === "fraga_besvarad") besvarade.add(`${h.stegId}/${h.frageId}`);
|
||||||
|
if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
|
||||||
|
}
|
||||||
|
for (const steg of metodik.steg) {
|
||||||
|
for (const fraga of steg.fragor ?? []) {
|
||||||
|
if (!besvarade.has(`${steg.id}/${fraga.id}`)) return { steg, fraga, klart: false };
|
||||||
|
}
|
||||||
|
for (const kontroll of steg.kontroller ?? []) {
|
||||||
|
if (!utforda.has(`${steg.id}/${kontroll.id}`)) return { steg, kontroll, klart: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sista = metodik.steg[metodik.steg.length - 1];
|
||||||
|
return { steg: sista, klart: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// Projektioner: alla vyer (ärendebrief, tidrapport, kundrapport) är rena
|
||||||
|
// funktioner av händelseloggen. De kan alltid regenereras — loggen vinner
|
||||||
|
// om en vy och loggen skulle säga olika saker.
|
||||||
|
|
||||||
|
import type { Arende, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
|
||||||
|
import { TIDKATEGORI_LABEL } from "./domain";
|
||||||
|
import type { Metodik } from "./metodik";
|
||||||
|
import { nastaSteg } from "./metodik";
|
||||||
|
|
||||||
|
export function objekt(arende: Arende): Objekt | undefined {
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function felbeskrivning(arende: Arende): string | undefined {
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
if (post.handelse.typ === "felbeskrivning") return post.handelse.text;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arAvslutat(arende: Arende): boolean {
|
||||||
|
return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtfordKontroll {
|
||||||
|
text: string;
|
||||||
|
resultat?: string;
|
||||||
|
tidpunkt: string;
|
||||||
|
anvandare: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utfordaKontroller(arende: Arende): UtfordKontroll[] {
|
||||||
|
const resultat: UtfordKontroll[] = [];
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
const h = post.handelse;
|
||||||
|
if (h.typ === "kontroll_utford") {
|
||||||
|
resultat.push({ text: h.text, resultat: h.resultat, tidpunkt: post.tidpunkt, anvandare: post.anvandare });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] {
|
||||||
|
const utforda = new Set<string>();
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
const h = post.handelse;
|
||||||
|
if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
|
||||||
|
}
|
||||||
|
const kvar: string[] = [];
|
||||||
|
for (const steg of metodik.steg) {
|
||||||
|
for (const kontroll of steg.kontroller ?? []) {
|
||||||
|
if (!utforda.has(`${steg.id}/${kontroll.id}`)) kvar.push(kontroll.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kvar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observationer(arende: Arende): string[] {
|
||||||
|
const resultat: string[] = [];
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
const h = post.handelse;
|
||||||
|
if (h.typ === "observation") resultat.push(h.text);
|
||||||
|
if (h.typ === "matvarde") resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
|
||||||
|
}
|
||||||
|
return resultat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hypoteser(arende: Arende): { text: string; niva: Tillforlitlighet }[] {
|
||||||
|
const resultat: { text: string; niva: Tillforlitlighet }[] = [];
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
if (post.handelse.typ === "hypotes") resultat.push({ text: post.handelse.text, niva: post.handelse.niva });
|
||||||
|
}
|
||||||
|
return resultat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function foton(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
|
||||||
|
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
if (post.handelse.typ === "foto") {
|
||||||
|
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tidsredovisning: varje intervall mellan två händelser tillhör den kategori
|
||||||
|
// som var aktiv när intervallet började. Kategori styrs av kategori_byte-
|
||||||
|
// händelser; standard är aktiv felsökning. Paus räknas inte in i total tid.
|
||||||
|
export interface Tidsfordelning {
|
||||||
|
totalMs: number;
|
||||||
|
perKategori: Partial<Record<TidKategori, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tidsfordelning(arende: Arende, nu?: string): Tidsfordelning {
|
||||||
|
const poster = arende.handelser;
|
||||||
|
const perKategori: Partial<Record<TidKategori, number>> = {};
|
||||||
|
if (poster.length === 0) return { totalMs: 0, perKategori };
|
||||||
|
|
||||||
|
let kategori: TidKategori = "aktiv_felsokning";
|
||||||
|
let forra = new Date(poster[0].tidpunkt).getTime();
|
||||||
|
let avslutad = false;
|
||||||
|
|
||||||
|
const laggTill = (till: number) => {
|
||||||
|
const ms = Math.max(0, till - forra);
|
||||||
|
perKategori[kategori] = (perKategori[kategori] ?? 0) + ms;
|
||||||
|
forra = till;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const post of poster) {
|
||||||
|
laggTill(new Date(post.tidpunkt).getTime());
|
||||||
|
const h = post.handelse;
|
||||||
|
if (h.typ === "kategori_byte") kategori = h.kategori;
|
||||||
|
if (h.typ === "arende_avslutat") {
|
||||||
|
avslutad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!avslutad && nu) laggTill(new Date(nu).getTime());
|
||||||
|
|
||||||
|
let totalMs = 0;
|
||||||
|
for (const [k, ms] of Object.entries(perKategori)) {
|
||||||
|
if (k !== "paus") totalMs += ms;
|
||||||
|
}
|
||||||
|
return { totalMs, perKategori };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formateraTid(ms: number): string {
|
||||||
|
const minuter = Math.round(ms / 60000);
|
||||||
|
const h = Math.floor(minuter / 60);
|
||||||
|
const m = minuter % 60;
|
||||||
|
if (h === 0) return `${m} min`;
|
||||||
|
return `${h} tim ${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TillforlitlighetsRad {
|
||||||
|
niva: Tillforlitlighet;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tillforlitlighet(arende: Arende): TillforlitlighetsRad[] {
|
||||||
|
const rader: TillforlitlighetsRad[] = [];
|
||||||
|
if (objekt(arende)) rader.push({ niva: "hog", text: "Objekt identifierat" });
|
||||||
|
if (foton(arende).length > 0) rader.push({ niva: "hog", text: "Bilder dokumenterade" });
|
||||||
|
if (arende.handelser.some((p) => p.handelse.typ === "matvarde")) {
|
||||||
|
rader.push({ niva: "hog", text: "Mätvärden registrerade" });
|
||||||
|
}
|
||||||
|
if (utfordaKontroller(arende).length > 0) {
|
||||||
|
rader.push({ niva: "hog", text: "Kontroller dokumenterade" });
|
||||||
|
}
|
||||||
|
// Felorsaken är per definition inte verifierad förrän ärendet avslutats
|
||||||
|
// med en verifierad slutsats — MVP:t markerar den alltid som ej verifierad
|
||||||
|
// så länge ärendet är öppet.
|
||||||
|
if (!arAvslutat(arende)) {
|
||||||
|
rader.push({ niva: "medel", text: "Felorsak ännu inte verifierad" });
|
||||||
|
}
|
||||||
|
return rader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Brief {
|
||||||
|
objekt?: Objekt;
|
||||||
|
felbeskrivning?: string;
|
||||||
|
utfordaKontroller: UtfordKontroll[];
|
||||||
|
observationer: string[];
|
||||||
|
hypoteser: { text: string; niva: Tillforlitlighet }[];
|
||||||
|
ejKontrollerat: string[];
|
||||||
|
rekommenderatNastaSteg: string[];
|
||||||
|
totalArbetstid: string;
|
||||||
|
tillforlitlighet: TillforlitlighetsRad[];
|
||||||
|
avslutat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ärendebriefen: hela den strukturerade sammanfattningen, regenererbar
|
||||||
|
// ur loggen vid varje anrop.
|
||||||
|
export function brief(arende: Arende, metodik: Metodik, nu?: string): Brief {
|
||||||
|
const kvar = ejKontrollerat(arende, metodik);
|
||||||
|
const nasta = nastaSteg(arende, metodik);
|
||||||
|
const rekommenderat: string[] = [];
|
||||||
|
if (!arAvslutat(arende)) {
|
||||||
|
if (nasta.fraga) rekommenderat.push(`Besvara: ${nasta.fraga.text}`);
|
||||||
|
else if (nasta.kontroll) rekommenderat.push(nasta.kontroll.text);
|
||||||
|
for (const text of kvar.slice(0, 3)) {
|
||||||
|
if (!rekommenderat.includes(text)) rekommenderat.push(text);
|
||||||
|
}
|
||||||
|
if (rekommenderat.length === 0) {
|
||||||
|
rekommenderat.push("Samtliga kontroller i metodiken utförda. Verifiera slutsats eller utöka felsökningen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
objekt: objekt(arende),
|
||||||
|
felbeskrivning: felbeskrivning(arende),
|
||||||
|
utfordaKontroller: utfordaKontroller(arende),
|
||||||
|
observationer: observationer(arende),
|
||||||
|
hypoteser: hypoteser(arende),
|
||||||
|
ejKontrollerat: kvar,
|
||||||
|
rekommenderatNastaSteg: rekommenderat.slice(0, 3),
|
||||||
|
totalArbetstid: formateraTid(tidsfordelning(arende, nu).totalMs),
|
||||||
|
tillforlitlighet: tillforlitlighet(arende),
|
||||||
|
avslutat: arAvslutat(arende),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Överlämningsrapport som text — används vid "Lämna över arbete".
|
||||||
|
export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string): string {
|
||||||
|
const b = brief(arende, metodik, nu);
|
||||||
|
const rader: string[] = [];
|
||||||
|
rader.push(`ÖVERLÄMNING – Ärende #${arende.nummer}`);
|
||||||
|
if (b.objekt) rader.push(`Objekt: ${b.objekt.beskrivning} (${b.objekt.identifierare})${b.objekt.kund ? ` – Kund: ${b.objekt.kund}` : ""}`);
|
||||||
|
if (b.felbeskrivning) rader.push(`Felbeskrivning: ${b.felbeskrivning}`);
|
||||||
|
rader.push("");
|
||||||
|
rader.push("Utförda kontroller:");
|
||||||
|
if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)");
|
||||||
|
for (const k of b.utfordaKontroller) rader.push(` ✓ ${k.text}${k.resultat ? ` — ${k.resultat}` : ""}`);
|
||||||
|
rader.push("");
|
||||||
|
rader.push("Observationer:");
|
||||||
|
if (b.observationer.length === 0) rader.push(" (inga ännu)");
|
||||||
|
for (const o of b.observationer) rader.push(` • ${o}`);
|
||||||
|
if (b.hypoteser.length > 0) {
|
||||||
|
rader.push("");
|
||||||
|
rader.push("Hypoteser (ej verifierade):");
|
||||||
|
for (const h of b.hypoteser) rader.push(` ? ${h.text}`);
|
||||||
|
}
|
||||||
|
rader.push("");
|
||||||
|
rader.push("Ej kontrollerat:");
|
||||||
|
if (b.ejKontrollerat.length === 0) rader.push(" (allt i metodiken utfört)");
|
||||||
|
for (const e of b.ejKontrollerat) rader.push(` – ${e}`);
|
||||||
|
rader.push("");
|
||||||
|
rader.push("Rekommenderat nästa steg:");
|
||||||
|
b.rekommenderatNastaSteg.forEach((s, i) => rader.push(` ${i + 1}. ${s}`));
|
||||||
|
rader.push("");
|
||||||
|
rader.push(`Total arbetstid: ${b.totalArbetstid}`);
|
||||||
|
return rader.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tidsfordelningsRader(arende: Arende, nu?: string): { label: string; tid: string }[] {
|
||||||
|
const { perKategori } = tidsfordelning(arende, nu);
|
||||||
|
return (Object.entries(perKategori) as [TidKategori, number][])
|
||||||
|
.filter(([, ms]) => ms >= 30000)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, ms]) => ({ label: TIDKATEGORI_LABEL[k], tid: formateraTid(ms) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sistaAktivitet(arende: Arende): LoggPost | undefined {
|
||||||
|
return arende.handelser[arende.handelser.length - 1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Tillstånd för Guidad Felsökning (MVP).
|
||||||
|
// Persistens: localStorage. I produktionsversionen ersätts detta av ett
|
||||||
|
// API-first-backend med samma händelsemodell — append-only-loggen gör
|
||||||
|
// synkronisering (inkl. offline-läge) konfliktfri.
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { Arende, Handelse, Objekt } from "./domain";
|
||||||
|
import { nyLoggPost } from "./domain";
|
||||||
|
import { felbeskrivning } from "./projektioner";
|
||||||
|
import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
|
||||||
|
|
||||||
|
interface FelsokningState {
|
||||||
|
anvandare: string;
|
||||||
|
arenden: Record<string, Arende>;
|
||||||
|
nastaNummer: number;
|
||||||
|
sattAnvandare: (namn: string) => void;
|
||||||
|
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
|
||||||
|
laggTill: (arendeId: string, handelse: Handelse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFelsokning = create<FelsokningState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
anvandare: "",
|
||||||
|
arenden: {},
|
||||||
|
nastaNummer: 1,
|
||||||
|
|
||||||
|
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
|
||||||
|
|
||||||
|
skapaArende: (objekt, felbeskrivningText) => {
|
||||||
|
const { anvandare, nastaNummer } = get();
|
||||||
|
const id = `arende-${Date.now().toString(36)}`;
|
||||||
|
const arende: Arende = {
|
||||||
|
id,
|
||||||
|
nummer: nastaNummer,
|
||||||
|
skapad: new Date().toISOString(),
|
||||||
|
handelser: [
|
||||||
|
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
|
||||||
|
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
set((s) => ({
|
||||||
|
arenden: { ...s.arenden, [id]: arende },
|
||||||
|
nastaNummer: s.nastaNummer + 1,
|
||||||
|
}));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
laggTill: (arendeId, handelse) => {
|
||||||
|
const { anvandare } = get();
|
||||||
|
set((s) => {
|
||||||
|
const arende = s.arenden[arendeId];
|
||||||
|
if (!arende) return s;
|
||||||
|
return {
|
||||||
|
arenden: {
|
||||||
|
...s.arenden,
|
||||||
|
[arendeId]: {
|
||||||
|
...arende,
|
||||||
|
handelser: [...arende.handelser, nyLoggPost(anvandare, handelse)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: "guidad-felsokning" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function metodikForArende(arende: Arende | undefined): Metodik {
|
||||||
|
if (!arende) return GENERISK_METODIK;
|
||||||
|
const text = felbeskrivning(arende);
|
||||||
|
return text ? valjMetodik(text) : GENERISK_METODIK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Delade UI-komponenter för Guidad Felsökning.
|
||||||
|
// Designfilosofi: professionellt fabriksverktyg — hög kontrast, stora
|
||||||
|
// träffytor (handskvänligt), få val per skärm, inga distraktioner.
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Tillforlitlighet } from "./domain";
|
||||||
|
import { TILLFORLITLIGHET_LABEL } from "./domain";
|
||||||
|
|
||||||
|
export function FelsokningSkal({
|
||||||
|
rubrik,
|
||||||
|
tillbaka,
|
||||||
|
hoger,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
rubrik: string;
|
||||||
|
tillbaka?: { till: string; text: string };
|
||||||
|
hoger?: ReactNode;
|
||||||
|
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="mx-auto flex max-w-3xl items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{tillbaka && (
|
||||||
|
<Link to={tillbaka.till} className="text-sm font-semibold text-amber-400 hover:underline">
|
||||||
|
← {tillbaka.text}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight">{rubrik}</h1>
|
||||||
|
</div>
|
||||||
|
{hoger}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-3xl px-4 py-5 pb-24">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StorKnapp({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
variant = "primar",
|
||||||
|
disabled,
|
||||||
|
type = "button",
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
variant?: "primar" | "sekundar" | "fara";
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: "button" | "submit";
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const farg =
|
||||||
|
variant === "primar"
|
||||||
|
? "bg-amber-400 text-zinc-950 hover:bg-amber-300 active:bg-amber-500"
|
||||||
|
: variant === "fara"
|
||||||
|
? "bg-red-600 text-white hover:bg-red-500 active:bg-red-700"
|
||||||
|
: "bg-zinc-800 text-zinc-50 border-2 border-zinc-600 hover:bg-zinc-700 active:bg-zinc-600";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4">
|
||||||
|
{rubrik && <h2 className="mb-3 text-sm font-extrabold uppercase tracking-widest text-zinc-400">{rubrik}</h2>}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NivaBadge({ niva }: { niva: Tillforlitlighet }) {
|
||||||
|
return <span className="whitespace-nowrap text-sm font-bold">{TILLFORLITLIGHET_LABEL[niva]}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFalt({
|
||||||
|
label,
|
||||||
|
varde,
|
||||||
|
satt,
|
||||||
|
platshallare,
|
||||||
|
flerRad,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
varde: string;
|
||||||
|
satt: (v: string) => void;
|
||||||
|
platshallare?: string;
|
||||||
|
flerRad?: boolean;
|
||||||
|
}) {
|
||||||
|
const klass =
|
||||||
|
"w-full rounded-lg border-2 border-zinc-600 bg-zinc-950 px-4 py-3 text-lg text-zinc-50 placeholder-zinc-500 focus:border-amber-400 focus:outline-none";
|
||||||
|
return (
|
||||||
|
<label className="mb-3 block">
|
||||||
|
<span className="mb-1 block text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
|
||||||
|
{flerRad ? (
|
||||||
|
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
|
||||||
|
) : (
|
||||||
|
<input value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} className={klass} />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import type { Arende, Handelse, TidKategori } from "@/felsokning/domain";
|
||||||
|
import { TIDKATEGORI_LABEL, handelseRubrik } from "@/felsokning/domain";
|
||||||
|
import type { Metodik, NastaSteg } from "@/felsokning/metodik";
|
||||||
|
import { nastaSteg } from "@/felsokning/metodik";
|
||||||
|
import {
|
||||||
|
arAvslutat,
|
||||||
|
brief,
|
||||||
|
felbeskrivning,
|
||||||
|
formateraTid,
|
||||||
|
foton,
|
||||||
|
objekt,
|
||||||
|
overlamningstext,
|
||||||
|
sistaAktivitet,
|
||||||
|
tidsfordelning,
|
||||||
|
tidsfordelningsRader,
|
||||||
|
} from "@/felsokning/projektioner";
|
||||||
|
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
||||||
|
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
|
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
||||||
|
|
||||||
|
const FLIKAR = [
|
||||||
|
{ id: "guide", label: "Guide" },
|
||||||
|
{ id: "logg", label: "Logg" },
|
||||||
|
{ id: "brief", label: "Brief" },
|
||||||
|
{ id: "rapport", label: "Rapport" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Flik = (typeof FLIKAR)[number]["id"];
|
||||||
|
|
||||||
|
export default function ArendeSida() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
|
||||||
|
const laggTill = useFelsokning((s) => s.laggTill);
|
||||||
|
const [flik, setFlik] = useState<Flik>("guide");
|
||||||
|
const [nu, setNu] = useState(() => new Date().toISOString());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const metodik = metodikForArende(arende);
|
||||||
|
|
||||||
|
if (!arende || !id) {
|
||||||
|
return (
|
||||||
|
<FelsokningSkal rubrik="Ärendet hittades inte" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||||
|
<p className="text-lg text-zinc-300">Ärendet finns inte på den här enheten.</p>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = objekt(arende);
|
||||||
|
const avslutat = arAvslutat(arende);
|
||||||
|
const total = formateraTid(tidsfordelning(arende, nu).totalMs);
|
||||||
|
const skicka = (h: Handelse) => laggTill(id, h);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FelsokningSkal
|
||||||
|
rubrik={`#${arende.nummer} ${o?.beskrivning ?? ""}`}
|
||||||
|
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
|
||||||
|
hoger={
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-extrabold text-amber-400">{total}</p>
|
||||||
|
<p className="text-xs font-bold uppercase text-zinc-500">{avslutat ? "Avslutat" : "Pågår"}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<nav className="mb-4 grid grid-cols-4 gap-1 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-1">
|
||||||
|
{FLIKAR.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => setFlik(f.id)}
|
||||||
|
className={`min-h-12 rounded-md text-base font-extrabold transition-colors ${
|
||||||
|
flik === f.id ? "bg-amber-400 text-zinc-950" : "text-zinc-300 hover:bg-zinc-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{!avslutat && <InaktivitetsBanner arende={arende} nu={nu} skicka={skicka} />}
|
||||||
|
|
||||||
|
{flik === "guide" && (
|
||||||
|
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
|
||||||
|
)}
|
||||||
|
{flik === "logg" && <LoggFlik arende={arende} />}
|
||||||
|
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} />}
|
||||||
|
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} />}
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vid längre inaktivitet ber systemet om sammanhang — svaret blir en loggpost.
|
||||||
|
function InaktivitetsBanner({
|
||||||
|
arende,
|
||||||
|
nu,
|
||||||
|
skicka,
|
||||||
|
}: {
|
||||||
|
arende: Arende;
|
||||||
|
nu: string;
|
||||||
|
skicka: (h: Handelse) => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const sista = sistaAktivitet(arende);
|
||||||
|
if (!sista) return null;
|
||||||
|
const minuter = Math.floor((new Date(nu).getTime() - new Date(sista.tidpunkt).getTime()) / 60000);
|
||||||
|
if (minuter < 20) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg border-2 border-amber-400 bg-zinc-900 p-4">
|
||||||
|
<p className="mb-2 text-lg font-bold text-amber-400">
|
||||||
|
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-zinc-300">Beskriv kort vad som gjorts under denna period.</p>
|
||||||
|
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." />
|
||||||
|
<StorKnapp
|
||||||
|
disabled={!text.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
skicka({ typ: "inaktivitet_forklarad", text: text.trim(), minuter });
|
||||||
|
setText("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Lägg till i arbetsloggen
|
||||||
|
</StorKnapp>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideFlik({
|
||||||
|
arende,
|
||||||
|
metodik,
|
||||||
|
avslutat,
|
||||||
|
skicka,
|
||||||
|
nu,
|
||||||
|
}: {
|
||||||
|
arende: Arende;
|
||||||
|
metodik: Metodik;
|
||||||
|
avslutat: boolean;
|
||||||
|
skicka: (h: Handelse) => void;
|
||||||
|
nu: string;
|
||||||
|
}) {
|
||||||
|
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
|
||||||
|
const [visaOverlamning, setVisaOverlamning] = useState(false);
|
||||||
|
|
||||||
|
if (avslutat) {
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Felsökningen är avslutad">
|
||||||
|
<p className="text-lg text-zinc-300">
|
||||||
|
Ärendet är låst för nya guidesteg. Loggen, briefen och rapporten finns kvar för spårbarhet och export.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<KategoriRad arende={arende} skicka={skicka} />
|
||||||
|
<Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}>
|
||||||
|
{steg.klart ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-xl font-bold">Samtliga steg i metodiken är dokumenterade.</p>
|
||||||
|
<p className="mb-4 text-zinc-300">
|
||||||
|
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
|
||||||
|
med rekommenderade nästa steg.
|
||||||
|
</p>
|
||||||
|
<StorKnapp variant="fara" onClick={() => skicka({ typ: "arende_avslutat" })}>
|
||||||
|
Avsluta felsökning
|
||||||
|
</StorKnapp>
|
||||||
|
</>
|
||||||
|
) : steg.fraga ? (
|
||||||
|
<FrageKort key={`${steg.steg.id}/${steg.fraga.id}`} steg={steg} skicka={skicka} />
|
||||||
|
) : steg.kontroll ? (
|
||||||
|
<KontrollKort key={`${steg.steg.id}/${steg.kontroll.id}`} steg={steg} skicka={skicka} />
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<SnabbDokumentation skicka={skicka} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
||||||
|
Lämna över arbete
|
||||||
|
</StorKnapp>
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "arende_avslutat" })}>
|
||||||
|
Avsluta felsökning
|
||||||
|
</StorKnapp>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visaOverlamning && (
|
||||||
|
<OverlamningDialog arende={arende} metodik={metodik} nu={nu} skicka={skicka} stang={() => setVisaOverlamning(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
|
||||||
|
let aktiv: TidKategori = "aktiv_felsokning";
|
||||||
|
for (const post of arende.handelser) {
|
||||||
|
if (post.handelse.typ === "kategori_byte") aktiv = post.handelse.kategori;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{(Object.keys(TIDKATEGORI_LABEL) as TidKategori[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => k !== aktiv && skicka({ typ: "kategori_byte", kategori: k })}
|
||||||
|
className={`whitespace-nowrap rounded-full border-2 px-4 py-2 text-sm font-extrabold transition-colors ${
|
||||||
|
k === aktiv
|
||||||
|
? "border-amber-400 bg-amber-400 text-zinc-950"
|
||||||
|
: "border-zinc-600 bg-zinc-900 text-zinc-300 hover:border-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{TIDKATEGORI_LABEL[k]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// En fråga i taget, stora svarsknappar.
|
||||||
|
function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
|
||||||
|
const fraga = steg.fraga!;
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const svara = (svar: string) =>
|
||||||
|
skicka({ typ: "fraga_besvarad", stegId: steg.steg.id, frageId: fraga.id, fraga: fraga.text, svar });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-2xl font-extrabold leading-snug">{fraga.text}</p>
|
||||||
|
{fraga.svarstyp === "janej" && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp onClick={() => svara("Ja")}>Ja</StorKnapp>
|
||||||
|
<StorKnapp onClick={() => svara("Nej")}>Nej</StorKnapp>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fraga.svarstyp === "val" && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{fraga.val!.map((v) => (
|
||||||
|
<StorKnapp key={v} variant="sekundar" onClick={() => svara(v)}>
|
||||||
|
{v}
|
||||||
|
</StorKnapp>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fraga.svarstyp === "text" && (
|
||||||
|
<>
|
||||||
|
<TextFalt label="Svar" varde={text} satt={setText} />
|
||||||
|
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
|
||||||
|
Spara svar
|
||||||
|
</StorKnapp>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
|
||||||
|
const kontroll = steg.kontroll!;
|
||||||
|
const [resultat, setResultat] = useState("");
|
||||||
|
const filRef = useRef<HTMLInputElement>(null);
|
||||||
|
const arFoto = /fotografera|foto/i.test(kontroll.text);
|
||||||
|
|
||||||
|
const utford = () =>
|
||||||
|
skicka({
|
||||||
|
typ: "kontroll_utford",
|
||||||
|
stegId: steg.steg.id,
|
||||||
|
kontrollId: kontroll.id,
|
||||||
|
text: kontroll.text,
|
||||||
|
resultat: resultat.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{steg.steg.beskrivning && <p className="mb-2 text-zinc-400">{steg.steg.beskrivning}</p>}
|
||||||
|
<p className="mb-4 text-2xl font-extrabold leading-snug">{kontroll.text}</p>
|
||||||
|
{arFoto && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={filRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const fil = e.target.files?.[0];
|
||||||
|
if (!fil) return;
|
||||||
|
const dataUrl = await skalaNerFoto(fil);
|
||||||
|
skicka({ typ: "foto", beskrivning: kontroll.text, dataUrl });
|
||||||
|
utford();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StorKnapp className="mb-2" onClick={() => filRef.current?.click()}>
|
||||||
|
📷 Ta / välj foto
|
||||||
|
</StorKnapp>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TextFalt label="Resultat / avläst värde (valfritt)" varde={resultat} satt={setResultat} platshallare="T.ex. 2,4 bar samtliga hjul" />
|
||||||
|
<StorKnapp onClick={utford}>Markera utförd</StorKnapp>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOKTYPER = [
|
||||||
|
{ id: "observation", label: "Observation" },
|
||||||
|
{ id: "matvarde", label: "Mätvärde" },
|
||||||
|
{ id: "foto", label: "Foto" },
|
||||||
|
{ id: "hypotes", label: "Hypotes" },
|
||||||
|
{ id: "kommentar", label: "Kommentar" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
|
||||||
|
// ej verifierade — de kan aldrig loggas som konstaterade fel.
|
||||||
|
function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
|
||||||
|
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [varde, setVarde] = useState("");
|
||||||
|
const filRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const aterstall = () => {
|
||||||
|
setTyp(null);
|
||||||
|
setText("");
|
||||||
|
setVarde("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const spara = () => {
|
||||||
|
const t = text.trim();
|
||||||
|
if (typ === "observation" && t) skicka({ typ: "observation", text: t });
|
||||||
|
if (typ === "kommentar" && t) skicka({ typ: "kommentar", text: t });
|
||||||
|
if (typ === "hypotes" && t) skicka({ typ: "hypotes", text: t, niva: "lag" });
|
||||||
|
if (typ === "matvarde" && t && varde.trim()) skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
|
||||||
|
aterstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Dokumentera">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{DOKTYPER.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => (d.id === "foto" ? filRef.current?.click() : setTyp(typ === d.id ? null : d.id))}
|
||||||
|
className={`min-h-12 rounded-lg border-2 text-sm font-extrabold transition-colors ${
|
||||||
|
typ === d.id
|
||||||
|
? "border-amber-400 bg-amber-400 text-zinc-950"
|
||||||
|
: "border-zinc-600 bg-zinc-900 text-zinc-200 hover:border-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
+ {d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={filRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const fil = e.target.files?.[0];
|
||||||
|
if (!fil) return;
|
||||||
|
const dataUrl = await skalaNerFoto(fil);
|
||||||
|
skicka({ typ: "foto", beskrivning: "Foto", dataUrl });
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{typ && typ !== "foto" && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{typ === "hypotes" && (
|
||||||
|
<p className="mb-2 text-sm font-bold text-red-400">
|
||||||
|
🔴 En hypotes är en möjlig felorsak som kräver verifiering — den loggas aldrig som ett konstaterat fel.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<TextFalt
|
||||||
|
label={typ === "matvarde" ? "Vad mättes?" : DOKTYPER.find((d) => d.id === typ)!.label}
|
||||||
|
varde={text}
|
||||||
|
satt={setText}
|
||||||
|
platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""}
|
||||||
|
/>
|
||||||
|
{typ === "matvarde" && <TextFalt label="Värde (med enhet)" varde={varde} satt={setVarde} platshallare="T.ex. 13,9 V" />}
|
||||||
|
<StorKnapp disabled={!text.trim() || (typ === "matvarde" && !varde.trim())} onClick={spara}>
|
||||||
|
Spara
|
||||||
|
</StorKnapp>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverlamningDialog({
|
||||||
|
arende,
|
||||||
|
metodik,
|
||||||
|
nu,
|
||||||
|
skicka,
|
||||||
|
stang,
|
||||||
|
}: {
|
||||||
|
arende: Arende;
|
||||||
|
metodik: Metodik;
|
||||||
|
nu: string;
|
||||||
|
skicka: (h: Handelse) => void;
|
||||||
|
stang: () => void;
|
||||||
|
}) {
|
||||||
|
const anvandare = useFelsokning((s) => s.anvandare);
|
||||||
|
const [till, setTill] = useState("");
|
||||||
|
const text = overlamningstext(arende, metodik, nu);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
|
||||||
|
<div className="max-h-full w-full max-w-2xl overflow-y-auto rounded-lg border-2 border-zinc-600 bg-zinc-900 p-4">
|
||||||
|
<h2 className="mb-3 text-xl font-extrabold">Överlämningsrapport</h2>
|
||||||
|
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg bg-zinc-950 p-4 text-sm text-zinc-200">
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
<TextFalt label="Lämnas till (valfritt)" varde={till} satt={setTill} platshallare="T.ex. Johan" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp variant="sekundar" onClick={stang}>
|
||||||
|
Avbryt
|
||||||
|
</StorKnapp>
|
||||||
|
<StorKnapp
|
||||||
|
onClick={() => {
|
||||||
|
skicka({ typ: "overlamning", fran: anvandare, till: till.trim() || undefined });
|
||||||
|
stang();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bekräfta överlämning
|
||||||
|
</StorKnapp>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoggFlik({ arende }: { arende: Arende }) {
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
|
||||||
|
<ol>
|
||||||
|
{arende.handelser.map((post) => (
|
||||||
|
<li key={post.id} className="flex gap-3 border-b border-zinc-800 py-2 last:border-0">
|
||||||
|
<span className="w-14 shrink-0 font-mono text-sm font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base text-zinc-100">{handelseRubrik(post)}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{post.anvandare}</p>
|
||||||
|
{post.handelse.typ === "foto" && (
|
||||||
|
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-zinc-700" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BriefFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) {
|
||||||
|
const b = brief(arende, metodik, nu);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Panel rubrik="Objekt">
|
||||||
|
{b.objekt ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xl font-extrabold">{b.objekt.beskrivning}</p>
|
||||||
|
<p className="font-bold text-amber-400">{b.objekt.identifierare}</p>
|
||||||
|
{b.objekt.kund && <p className="text-zinc-300">Kund: {b.objekt.kund}</p>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-zinc-400">Ej identifierat</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik="Kundens beskrivning">
|
||||||
|
<p className="text-lg">{b.felbeskrivning ? `”${b.felbeskrivning}”` : "—"}</p>
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik="Utförda kontroller">
|
||||||
|
{b.utfordaKontroller.length === 0 && <p className="text-zinc-400">Inga ännu.</p>}
|
||||||
|
{b.utfordaKontroller.map((k, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg">
|
||||||
|
✓ {k.text}
|
||||||
|
{k.resultat && <span className="text-zinc-400"> — {k.resultat}</span>}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik="Observationer">
|
||||||
|
{b.observationer.length === 0 && <p className="text-zinc-400">Inga ännu.</p>}
|
||||||
|
{b.observationer.map((o, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg">• {o}</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
{b.hypoteser.length > 0 && (
|
||||||
|
<Panel rubrik="Hypoteser — kräver verifiering">
|
||||||
|
{b.hypoteser.map((h, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg">
|
||||||
|
<NivaBadge niva={h.niva} /> {h.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
<Panel rubrik="Ej kontrollerat">
|
||||||
|
{b.ejKontrollerat.length === 0 && <p className="text-zinc-400">Allt i metodiken är utfört.</p>}
|
||||||
|
{b.ejKontrollerat.map((e, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg text-zinc-300">– {e}</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
{!b.avslutat && (
|
||||||
|
<Panel rubrik="Rekommenderat nästa steg">
|
||||||
|
{b.rekommenderatNastaSteg.map((s, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg">{i + 1}. {s}</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
<Panel rubrik="Tillförlitlighet">
|
||||||
|
{b.tillforlitlighet.map((r, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg">
|
||||||
|
<NivaBadge niva={r.niva} /> {r.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik="Total arbetstid">
|
||||||
|
<p className="text-2xl font-extrabold">{b.totalArbetstid}</p>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kundrapporten: en tydlig tidslinje i stället för "Felsökning – 2,5 timmar".
|
||||||
|
// Interna poster (kategoribyten) visas inte för kund.
|
||||||
|
function RapportFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) {
|
||||||
|
const b = brief(arende, metodik, nu);
|
||||||
|
const bilder = foton(arende);
|
||||||
|
const fordelning = tidsfordelningsRader(arende, nu);
|
||||||
|
const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Panel rubrik="Kundrapport">
|
||||||
|
<p className="mb-2 text-zinc-300">
|
||||||
|
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas — bilder kan
|
||||||
|
innehålla uppgifter om andra kunder.
|
||||||
|
</p>
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => window.print()}>
|
||||||
|
Skriv ut / spara som PDF
|
||||||
|
</StorKnapp>
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik={`Ärende #${arende.nummer}`}>
|
||||||
|
{b.objekt && (
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{b.objekt.beskrivning} · {b.objekt.identifierare}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{b.felbeskrivning && <p className="mt-1 text-lg">Felbeskrivning: ”{b.felbeskrivning}”</p>}
|
||||||
|
<p className="mt-1 text-lg">Total arbetstid: <span className="font-extrabold">{b.totalArbetstid}</span></p>
|
||||||
|
{fordelning.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{fordelning.map((r) => (
|
||||||
|
<p key={r.label} className="text-zinc-300">{r.label}: {r.tid}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
{bilder.length > 0 && (
|
||||||
|
<Panel rubrik="Dokumenterade 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>
|
||||||
|
)}
|
||||||
|
{!b.avslutat || b.ejKontrollerat.length > 0 ? (
|
||||||
|
<Panel rubrik="Rekommenderade nästa steg">
|
||||||
|
{(b.avslutat ? b.ejKontrollerat : b.rekommenderatNastaSteg).map((s, i) => (
|
||||||
|
<p key={i} className="py-0.5 text-lg">{i + 1}. {s}</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
<p className="text-center text-xs text-zinc-600">
|
||||||
|
Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
|
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||||
|
|
||||||
|
export default function Arendelista() {
|
||||||
|
const { anvandare, arenden, sattAnvandare } = useFelsokning();
|
||||||
|
const [namn, setNamn] = useState("");
|
||||||
|
|
||||||
|
if (!anvandare) {
|
||||||
|
return (
|
||||||
|
<FelsokningSkal rubrik="Guidad Felsökning">
|
||||||
|
<Panel rubrik="Vem arbetar?">
|
||||||
|
<p className="mb-3 text-lg text-zinc-300">
|
||||||
|
Allt arbete loggas med användare och tidpunkt. Ange ditt namn för att börja.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (namn.trim()) sattAnvandare(namn);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextFalt label="Namn" varde={namn} satt={setNamn} platshallare="T.ex. Anna" />
|
||||||
|
<StorKnapp type="submit" disabled={!namn.trim()}>
|
||||||
|
Fortsätt
|
||||||
|
</StorKnapp>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lista = Object.values(arenden).sort((a, b) => b.skapad.localeCompare(a.skapad));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FelsokningSkal
|
||||||
|
rubrik="Guidad Felsökning"
|
||||||
|
hoger={<span className="text-sm font-bold text-zinc-400">{anvandare}</span>}
|
||||||
|
>
|
||||||
|
<Link to="/felsokning/nytt">
|
||||||
|
<StorKnapp className="mb-5">+ 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lista.map((arende) => {
|
||||||
|
const o = objekt(arende);
|
||||||
|
const fel = felbeskrivning(arende);
|
||||||
|
const sista = sistaAktivitet(arende);
|
||||||
|
const avslutat = arAvslutat(arende);
|
||||||
|
const b = brief(arende, metodikForArende(arende), new Date().toISOString());
|
||||||
|
return (
|
||||||
|
<Link key={arende.id} to={`/felsokning/arende/${arende.id}`} className="block">
|
||||||
|
<div className="mb-3 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4 transition-colors hover:border-amber-400">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">
|
||||||
|
Ärende #{arende.nummer}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs font-extrabold uppercase ${
|
||||||
|
avslutat ? "bg-zinc-700 text-zinc-300" : "bg-amber-400 text-zinc-950"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{avslutat ? "Avslutat" : "Pågående"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xl font-extrabold">{o ? o.beskrivning : "Okänt objekt"}</p>
|
||||||
|
{o && <p className="text-sm font-bold text-zinc-400">{o.identifierare}{o.kund ? ` · ${o.kund}` : ""}</p>}
|
||||||
|
{fel && <p className="mt-2 text-lg text-zinc-200">”{fel}”</p>}
|
||||||
|
<p className="mt-2 text-sm text-zinc-500">
|
||||||
|
{sista ? `Senaste aktivitet ${tidDatum(sista.tidpunkt)} ${tidKlockslag(sista.tidpunkt)}` : ""}
|
||||||
|
{" · "}
|
||||||
|
{b.totalArbetstid}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { Objekt } from "@/felsokning/domain";
|
||||||
|
import { useFelsokning } from "@/felsokning/store";
|
||||||
|
import { valjMetodik } from "@/felsokning/metodik";
|
||||||
|
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
|
|
||||||
|
const IDENTIFIERINGSMETODER = ["Registreringsnummer", "VIN", "Serienummer", "Maskinnummer", "Manuell inmatning"];
|
||||||
|
const OBJEKTTYPER = ["Fordon", "Lastbil/Buss", "Entreprenadmaskin", "Industrimaskin", "Elsystem", "Hydraulik", "Övrigt"];
|
||||||
|
|
||||||
|
// Ingen felsökning börjar innan objektet identifierats och bekräftats.
|
||||||
|
export default function NyttArende() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const skapaArende = useFelsokning((s) => s.skapaArende);
|
||||||
|
|
||||||
|
const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera");
|
||||||
|
const [metod, setMetod] = useState(IDENTIFIERINGSMETODER[0]);
|
||||||
|
const [typ, setTyp] = useState(OBJEKTTYPER[0]);
|
||||||
|
const [identifierare, setIdentifierare] = useState("");
|
||||||
|
const [beskrivning, setBeskrivning] = useState("");
|
||||||
|
const [kund, setKund] = useState("");
|
||||||
|
const [fel, setFel] = useState("");
|
||||||
|
|
||||||
|
const objektet: Objekt = {
|
||||||
|
typ,
|
||||||
|
identifierare: identifierare.trim().toUpperCase(),
|
||||||
|
identifieringsmetod: metod,
|
||||||
|
beskrivning: beskrivning.trim() || identifierare.trim().toUpperCase(),
|
||||||
|
kund: kund.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (steg === "identifiera") {
|
||||||
|
return (
|
||||||
|
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||||
|
<Panel rubrik="Objekttyp">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{OBJEKTTYPER.map((t) => (
|
||||||
|
<StorKnapp key={t} variant={t === typ ? "primar" : "sekundar"} onClick={() => setTyp(t)}>
|
||||||
|
{t}
|
||||||
|
</StorKnapp>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik="Identifieringsmetod">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{IDENTIFIERINGSMETODER.map((m) => (
|
||||||
|
<StorKnapp key={m} variant={m === metod ? "primar" : "sekundar"} onClick={() => setMetod(m)}>
|
||||||
|
{m}
|
||||||
|
</StorKnapp>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-zinc-500">
|
||||||
|
QR-kod, streckkod och OCR från typskylt tillkommer i senare version — ange identiteten manuellt.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
<Panel rubrik="Objektets identitet">
|
||||||
|
<TextFalt label={metod} varde={identifierare} satt={setIdentifierare} platshallare="T.ex. ABC123" />
|
||||||
|
<TextFalt label="Beskrivning (märke, modell, år)" varde={beskrivning} satt={setBeskrivning} platshallare="T.ex. Volvo XC60 D4 2019" />
|
||||||
|
<TextFalt label="Kund (valfritt)" varde={kund} satt={setKund} platshallare="T.ex. Anders Svensson" />
|
||||||
|
<StorKnapp disabled={!identifierare.trim()} onClick={() => setSteg("bekrafta")}>
|
||||||
|
Fortsätt
|
||||||
|
</StorKnapp>
|
||||||
|
</Panel>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steg === "bekrafta") {
|
||||||
|
return (
|
||||||
|
<FelsokningSkal rubrik="Bekräfta objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||||
|
<Panel>
|
||||||
|
<p className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">{objektet.typ}</p>
|
||||||
|
<p className="mt-1 text-3xl font-extrabold">{objektet.beskrivning}</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-amber-400">{objektet.identifierare}</p>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">Identifierad via: {objektet.identifieringsmetod}</p>
|
||||||
|
{objektet.kund && <p className="mt-1 text-lg text-zinc-200">Kund: {objektet.kund}</p>}
|
||||||
|
</Panel>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
|
||||||
|
Ändra
|
||||||
|
</StorKnapp>
|
||||||
|
<StorKnapp onClick={() => setSteg("felbeskrivning")}>Rätt objekt — fortsätt</StorKnapp>
|
||||||
|
</div>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metodik = fel.trim() ? valjMetodik(fel) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FelsokningSkal rubrik="Beskriv felet" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||||
|
<Panel rubrik={`${objektet.beskrivning} · ${objektet.identifierare}`}>
|
||||||
|
<TextFalt
|
||||||
|
label="Kundens/användarens felbeskrivning"
|
||||||
|
varde={fel}
|
||||||
|
satt={setFel}
|
||||||
|
platshallare="T.ex. Bilen vibrerar runt 88 km/h"
|
||||||
|
flerRad
|
||||||
|
/>
|
||||||
|
{metodik && (
|
||||||
|
<p className="mb-3 text-sm font-bold text-zinc-400">
|
||||||
|
Vald metodik: <span className="text-amber-400">{metodik.namn}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<StorKnapp
|
||||||
|
disabled={!fel.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
const id = skapaArende(objektet, fel.trim());
|
||||||
|
navigate(`/felsokning/arende/${id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Starta arbetslogg
|
||||||
|
</StorKnapp>
|
||||||
|
</Panel>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user