diff --git a/docs/MVP.md b/docs/MVP.md index 2a28d0f..adcc4a1 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -27,7 +27,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Röstinmatning (tal in, text ut) | ✅ Push-to-Talk via webbläsarens taligenkänning (sv-SE): lyssnar bara efter aktivt tryck, röd indikator med realtidstranskript, texten hamnar i ett redigerbart fält och skickas aldrig automatiskt. Knappen visas bara i webbläsare med talstöd. Produktionsversionen byter motor till leverantörens Voice-to-Text bakom samma gränssnitt. | | Verifierade checklistor | ✅ Varje kontroll i metodiken har ett minimikrav (foto, mätvärde eller kort observation). Foto-kontroller verifieras med bild; mätningar kan inte markeras verifierade utan värde. | | 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. | -| Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. **Arbetsledarvy** (`/felsokning/oversikt`): organisationens alla ärenden med status, deltagande tekniker och statistik (pågående/avslutade/ledtid) — härlett ur händelseloggen; ärenden kan hämtas till enheten med konfliktfri flätning. **Ansvarig tekniker** per ärende härleds ur loggen (skapare → överlämning → omfördelning) och arbetsledaren kan omfördela pågående ärenden — loggat som den organisationsinterna händelsen `ansvarig_satt`, aldrig synlig i kund-/partnerdelningar. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only, översiktens behörighet och härledningar). | +| Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. **Arbetsledarvy** (`/felsokning/oversikt`): organisationens alla ärenden med status, deltagande tekniker och statistik (pågående/avslutade/ledtid) — härlett ur händelseloggen; ärenden kan hämtas till enheten med konfliktfri flätning. **Felorsaksstatistik** (flottdata): orsakskategorierna ur alla felorsaksanalyser aggregeras per organisation och visas som stapelöversikt i arbetsledarvyn. **Ansvarig tekniker** per ärende härleds ur loggen (skapare → överlämning → omfördelning) och arbetsledaren kan omfördela pågående ärenden — loggat som den organisationsinterna händelsen `ansvarig_satt`, aldrig synlig i kund-/partnerdelningar. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only, översiktens behörighet och härledningar). | | 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. 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**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. | @@ -35,7 +35,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Ärendestart via arbetsorder | ✅ Primärvägen när ett ärende startas: fota arbetsorderns framsida — orkesterns dokumenttolkning (Claude Sonnet 5, vision) läser kund-, fordons- och verkstadsuppgifter oavsett layout och sätter konfidens per fält. 🟢 ≥95 % godkänns automatiskt, 🟡 80–95 % markeras för genomläsning, 🔴 <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält. Visuell granskning med dokumentet bredvid fälten (klick markerar ungefärlig position), sedan skapas hela ärendet med ett tryck. Tolkningen loggas som organisationsintern händelse (`arbetsorder_skannad`) och delas aldrig i kund-/partnervyer. Manuell inmatning finns kvar som andrahandsväg; i lokalt läge visas en tydligt märkt demo-tolkning. Inloggade användare tillfrågas aldrig om namn — kontot vet redan. | | Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. | | Evidensmotor (ECM) | ✅ Eget subsystem med sex motorer ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, **ECM v2.0**): Evidence (evidensposter med nivå E0–E6, tekniker och innehållshash), Rule (dokumentationskrav + undantagsregeln med obligatorisk orsak), Compliance (ärendetypen — garanti/försäkring/reklamation m.fl. — styr extra krav), Validation ("Evidens saknas" i stället för antaganden, kodat i orkesterns grundprompt), Completion (kvalitetsgrind som spärrar slutrapporten) och Traceability (spårbarhetspaket med regelversion + hash i varje export). **ECM Knowledge Library**: compliance-reglerna är deklarativ data som serveras av plattformen (`GET /api/ecm/regler`, utbytbar via ConfigMap) — uppdateras i driften utan appändring; klienten cachar och faller tillbaka till inbyggt standardpaket offline. | -| Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik (Ja med ev. orsakskedja / Nej med obligatorisk orsak → kvalitetsvarning), **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. | +| Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik — tidigare ärenden på samma fordon hämtas automatiskt med sina felorsaker (server i inloggat läge, lokala storen annars) och orsakskedjan kopplas med ett tryck; Ja/Nej med obligatorisk orsak → kvalitetsvarning, **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. | | Symptomverifiering (SVP) | ✅ Kundens beskrivning ≠ konstaterat fel: beskrivningen dokumenteras ordagrant, förtydligas via metodikens symptomfrågor (generiska metodiken har SVP-setet när/var/hur) och reproduceras — Ja (hur/förhållanden), Delvis (vad kunde/kunde inte) eller Nej (obligatorisk motivering). Rapportens beviskedja skiljer kundens beskrivning, verifierad observation, felorsaksanalys och rekommenderad åtgärd; formuleringen "kunde inte reproduceras under de förhållanden som rådde" används i stället för "felet konstaterat" (kodat även i orkesterns grundprompt). | | Felorsaksanalys | ✅ Obligatorisk före avslut: konstaterad avvikelse (kvalitetsregeln avvisar "trasig/defekt/sliten" utan förklaring), orsakskategorier (inkl. Okänd orsak med krav på motivering), minst en evidenskälla som valideras mot loggen, säkerhetsnivå (medel/låg kräver stärkande kontroller) och rekommenderad åtgärd. Avslutsknappen spärrad tills SVP + felorsak finns; kvalitetsgrinden gör båda obligatoriska vid stängning. Eget avsnitt i slutrapporten. | | Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. | @@ -50,6 +50,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st - **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. - **Ingen slutsats utan evidens.** `src/felsokning/ecm.ts` — regelmotorn (ECM) validerar varje påstående mot händelseloggen: fullbordansregler, evidensnivåer och kvalitetsgrind. Kameran är integrationslagret (visual-first) — det som syns på en skärm eller ett instrument fotograferas och tolkas i stället för att integreras. - **Terminologi.** Produkten beskrivs som ett evidensbaserat diagnossystem/intelligent beslutsstöd — i UI och kundkommunikation används *systemet/analysen/bedömningen/beslutsstödet*, aldrig "AI" om det inte är tekniskt nödvändigt. +- **Egen ikongrafik.** `src/felsokning/ikoner.tsx` — enkla industriella linjeikoner (SVG, stroke i aktuell textfärg) i stället för emojis; tillförlitlighets- och statusnivåer visas som färgpunkter. - **Industriellt verkstads-UI (ETKA-inspirerat).** `src/felsokning/ui.tsx` — plana ljusgrå ytor (#ECECEC/#F7F7F7), skarpa kanter, djup marinblå som primärfärg, tät typografi (11–15 px), rektangulära knappar (max 4 px radie), verktygsrad ~44 px. Ärendesidan har klassisk trekolumnslayout på skrivbord: navigationsträd (vyer + metodikstegens status) till vänster, arbetsyta i mitten, kontextpanel (teknisk information, tillförlitlighet, teknisk rekommendation) till höger; en kolumn med flikrad på smala skärmar. ## Medvetna avgränsningar diff --git a/docs/moduler/evidensmotor.md b/docs/moduler/evidensmotor.md index a581784..e72232c 100644 --- a/docs/moduler/evidensmotor.md +++ b/docs/moduler/evidensmotor.md @@ -94,9 +94,13 @@ vilken mätning → vilken tekniker → vilken regel → vilken regelverksversio Ingen felsökning påbörjas förrän grundkontrollerna är genomförda eller dokumenterat motiverade — metodiken låses upp först därefter: -1. **Fordonshistorik** — kontrollerad (tidigare arbeten, återkommande - fel, TSB, kampanjer; relevanta samband kan noteras som orsakskedja) - eller Nej med obligatorisk orsak → kvalitetsvarning. +1. **Fordonshistorik** — systemet hämtar automatiskt organisationens + tidigare ärenden på samma objekt (regnr/VIN) med deras dokumenterade + felorsaker (`GET /api/fordon/{identifierare}/historik`; lokala storen + offline) och visar dem i historiksteget. Teknikern kan koppla + **orsakskedjan** till det aktuella ärendet med ett tryck ("kopplat + till tidigare ärende #N — …"), kvitterar kontrollen — eller anger Nej + med obligatorisk orsak → kvalitetsvarning. 2. **Ingående mätarställning** — instrumentpanelen fotograferas; bildtolkningen föreslår värdet, teknikern bekräftar. Fotot blir den officiella ingående mätarställningen. @@ -148,10 +152,11 @@ Varje konstaterat fel kräver fyra obligatoriska svar: ytterligare kontroller som skulle stärka bedömningen. Avslutsknappen är spärrad tills SVP + felorsaksanalys är dokumenterade, -och kvalitetsgrinden gör båda obligatoriska när ärendet stängs. Efter -tusentals ärenden ger orsakskategorierna dessutom flottdata: vilka -komponenter fallerar av slitage, vilka efter tidigare reparationer, -vilka tyder på konstruktionsproblem. +och kvalitetsgrinden gör båda obligatoriska när ärendet stängs. +Flottdatan är redan igång: **felorsaksstatistiken** i arbetsledarvyn +(`GET /api/statistik/felorsaker`) aggregerar orsakskategorierna över +organisationen — vilka komponenter fallerar av slitage, vilka efter +tidigare reparationer, vilka tyder på konstruktionsproblem. ## Ärendeidentitet (Case Identity & Vehicle Context) diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 953b242..20272d6 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -166,6 +166,29 @@ curl -s -X POST "$BAS/api/delningar/$PARTNERKOD/aterkalla" -H "Authorization: Be KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$PARTNERKOD") kontroll "återkallad delning ger 404" "$KOD" "404" +# 10a. Fordonshistorik och felorsaksstatistik +curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"id":"arende-test2","nummer":2,"skapad":"2026-08-03T09:00:00Z"}' >/dev/null +curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"id":"arende-test3","nummer":3,"skapad":"2026-08-03T10:00:00Z"}' >/dev/null +curl -s -X POST "$BAS/api/arenden/arende-test2/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"handelser":[{"id":"f1","tidpunkt":"2026-08-03T09:01:00Z","anvandare":"Anna","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"XYZ999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}}]}' >/dev/null +curl -s -X POST "$BAS/api/arenden/arende-test3/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"handelser":[ + {"id":"f2","tidpunkt":"2026-08-03T10:01:00Z","anvandare":"Johan","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"xyz999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}}, + {"id":"f3","tidpunkt":"2026-08-03T10:02:00Z","anvandare":"Johan","handelse":{"typ":"felorsak","avvikelse":"Vattenpumpen läcker vid axeltätningen.","orsaker":["Normalt slitage","Ålder"],"underlag":["Foto"],"sakerhet":"hog","atgard":"Byt vattenpump."}} + ]}' >/dev/null +HIST=$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_J") +kontroll "fordonshistoriken hittar båda ärendena (case-okänsligt)" "$(echo "$HIST" | falt .arenden.length)" "2" +kontroll "historiken bär felorsakerna" "$(echo "$HIST" | falt '.arenden.flatMap(a=>a.felorsaker).length')" "1" +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_B") +kontroll "org B ser inte org A:s fordonshistorik" "$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)" "0" +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/statistik/felorsaker" -H "Authorization: Bearer $TOKEN_J") +kontroll "tekniker nekas felorsaksstatistiken" "$KOD" "403" +STAT=$(curl -s "$BAS/api/statistik/felorsaker" -H "Authorization: Bearer $TOKEN_L") +kontroll "statistiken räknar orsakskategorier" "$(echo "$STAT" | falt .orsaker.length)" "2" +kontroll "statistiken är organisationsknuten data" "$(echo "$STAT" | falt '.orsaker[0].antal')" "1" + # 10b. ECM Knowledge Library: regelpaketet serveras till inloggade klienter REGLER=$(curl -s "$BAS/api/ecm/regler" -H "Authorization: Bearer $TOKEN_J") kontroll "regelpaketet serveras" "$(echo "$REGLER" | falt .version)" "2.0" diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index b8d6e0a..88683a6 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -170,6 +170,65 @@ paths: orsakskategorier: { type: array, items: { type: string } } underlagskallor: { type: array, items: { type: string } } + /api/fordon/{identifierare}/historik: + get: + tags: [Ärenden] + summary: Fordonshistorik — tidigare ärenden på samma objekt + description: > + Organisationens tidigare ärenden där objektet (regnr/VIN) matchar, + med dokumenterade felorsaker. Underlag för pre-diagnostikens + historiksteg och orsakskedjan. + parameters: + - name: identifierare + in: path + required: true + schema: { type: string } + responses: + "200": + description: Tidigare ärenden (senaste 20). + content: + application/json: + schema: + type: object + properties: + arenden: + type: array + items: + type: object + properties: + id: { type: string } + nummer: { type: integer } + skapad: { type: string, format: date-time } + avslutat: { type: boolean } + felbeskrivning: { type: string, nullable: true } + felorsaker: { type: array, items: { type: object, additionalProperties: true } } + "401": { $ref: "#/components/responses/Fel" } + + /api/statistik/felorsaker: + get: + tags: [Översikt] + summary: Felorsaksstatistik per orsakskategori + description: > + Flottdata ur dokumenterade felorsaksanalyser i organisationen. + Kräver rollen `arbetsledare` eller `admin`. + responses: + "200": + description: Antal per orsakskategori, fallande. + content: + application/json: + schema: + type: object + properties: + orsaker: + type: array + items: + type: object + properties: + orsak: { type: string } + antal: { type: integer } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + /api/organisation: get: tags: [Organisation] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 91a78d9..e9be1a3 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -312,6 +312,59 @@ export function skapaServer() { return svara(res, 200, { ok: true }); } + // Fordonshistorik: organisationens tidigare ärenden på samma objekt + // (regnr/VIN), med dokumenterade felorsaker — pre-diagnostikens + // historiksteg och orsakskedjan. Organisationsgränsen gäller alltid. + const fordonVag = vag.match(/^\/api\/fordon\/([^/]+)\/historik$/); + if (req.method === "GET" && fordonVag) { + const ident = decodeURIComponent(fordonVag[1]).trim().toUpperCase(); + if (!ident) return svara(res, 400, { error: "Identifierare saknas." }); + const rader = await pool.query( + `select a.id, a.nummer, a.skapad, + coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat, + (array_agg(h.handelse->>'text') + filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning, + coalesce(json_agg(json_build_object( + 'avvikelse', h.handelse->>'avvikelse', + 'orsaker', h.handelse->'orsaker', + 'atgard', h.handelse->>'atgard')) + filter (where h.handelse->>'typ' = 'felorsak'), '[]') as felorsaker + from felsokning_arenden a + join felsokning_handelser h on h.arende_id = a.id + where a.organisation_id = $1 + and a.id in ( + select arende_id from felsokning_handelser + where handelse->>'typ' = 'objekt_identifierat' + and upper(handelse->'objekt'->>'identifierare') = $2 + ) + group by a.id + order by a.skapad desc + limit 20`, + [anspr.org, ident], + ); + return svara(res, 200, { arenden: rader.rows }); + } + + // Flottdata: felorsaksstatistik per orsakskategori över hela + // organisationen (arbetsledare/admin) — återkommande fel blir + // synliga när orsakerna är strukturerad data. + if (req.method === "GET" && vag === "/api/statistik/felorsaker") { + if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") { + return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." }); + } + const rader = await pool.query( + `select orsak, count(*)::int as antal + from felsokning_arenden a + join felsokning_handelser h on h.arende_id = a.id, + jsonb_array_elements_text(h.handelse->'orsaker') as orsak + where a.organisation_id = $1 and h.handelse->>'typ' = 'felorsak' + group by orsak + order by antal desc`, + [anspr.org], + ); + return svara(res, 200, { orsaker: rader.rows }); + } + // Organisationsöversikt för arbetsledare/admin: alla ärenden med // status, deltagande tekniker och sammanfattning — härlett ur // händelseloggen, aldrig lagrat separat. diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index 169bc0d..d621209 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -10,6 +10,7 @@ import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader } from import { metodikForArende } from "./store"; import { tidDatum, tidKlockslag } from "./format"; import { FelsokningSkal, Panel } from "./ui"; +import { IkonCheck, IkonKlocka, IkonUppdatera } from "./ikoner"; function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) { const idn = arendeidentitet(arende); @@ -72,7 +73,7 @@ export function DelatArendeVy({ avslutat ? "bg-[#3E5A78] text-white" : "bg-[#1E6B34] text-white" }`} > - {avslutat ? "Avslutat" : "🟢 Felsökning pågår"} + {avslutat ? "Avslutat" : "Felsökning pågår"} } > @@ -93,11 +94,11 @@ export function DelatArendeVy({ {b.utfordaKontroller.map((k, i) => ( -

✔ {k.text}

+

{k.text}

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

🔄 {pagaende}

} + {!avslutat && pagaende &&

{pagaende}

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

⏳ {e}

+

{e}

))}
diff --git a/src/felsokning/Mikrofon.tsx b/src/felsokning/Mikrofon.tsx index 693058d..268ee86 100644 --- a/src/felsokning/Mikrofon.tsx +++ b/src/felsokning/Mikrofon.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { startaIgenkanning, stodRost } from "./rost"; +import { IkonMik } from "./ikoner"; export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) { const [lyssnar, setLyssnar] = useState(false); @@ -53,7 +54,7 @@ export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) { : "border-[#ADADAD] bg-white text-[#1A1A1A] hover:border-[#00437A]" }`} > - {lyssnar ? "■" : "🎤"} + {lyssnar ? "■" : } {lyssnar && ( diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index f6d47ae..569ffb9 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -77,6 +77,8 @@ describe("AI-orkestern", () => { ["/api/organisation/installningar", "/api/organisation/installningar"], ["/api/arenden", "/api/arenden"], ["/api/arenden/{arendeId}/handelser", "handelser"], + ["/api/fordon/{identifierare}/historik", "historik"], + ["/api/statistik/felorsaker", "/api/statistik/felorsaker"], ["/api/oversikt", "/api/oversikt"], ["/api/delad/{delningskod}", "delad"], ]; diff --git a/src/felsokning/__tests__/ecm.test.ts b/src/felsokning/__tests__/ecm.test.ts index 3c0d2fa..0f6610c 100644 --- a/src/felsokning/__tests__/ecm.test.ts +++ b/src/felsokning/__tests__/ecm.test.ts @@ -309,3 +309,29 @@ describe("ECM Knowledge Library (regelpaket)", () => { } }); }); + +describe("Fordonshistorik (lokal projektion)", () => { + it("hittar tidigare ärenden på samma identifierare med felorsaker", async () => { + const { lokalFordonshistorik } = await import("../projektioner"); + const tidigare = byggArende([ + OBJEKT, + { + typ: "felorsak", + avvikelse: "Vattenpumpen läcker vid axeltätningen.", + orsaker: ["Normalt slitage"], + underlag: ["Foto"], + sakerhet: "hog", + atgard: "Byt vattenpump.", + }, + { typ: "arende_avslutat" }, + ]); + const aktuellt = byggArende([OBJEKT]); + aktuellt.id = "nytt-arende"; + const historik = lokalFordonshistorik({ [tidigare.id]: tidigare, [aktuellt.id]: aktuellt }, "abc123", aktuellt.id); + expect(historik).toHaveLength(1); + expect(historik[0].avslutat).toBe(true); + expect(historik[0].felorsaker[0].orsaker).toContain("Normalt slitage"); + // Annat fordon → ingen träff. + expect(lokalFordonshistorik({ [tidigare.id]: tidigare }, "ZZZ111")).toHaveLength(0); + }); +}); diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts index 84c5d69..6f70a8a 100644 --- a/src/felsokning/ai.ts +++ b/src/felsokning/ai.ts @@ -34,7 +34,7 @@ export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendatio export const AI_RADTYP_LABEL: Record = { observation: "Observation", verifierat: "Verifierat", - hypotes: "🔴 Hypotes", + hypotes: "Hypotes (ej verifierad)", rekommendation: "Rekommendation", }; diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index 1f41e84..a123dfa 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -5,9 +5,9 @@ export type Tillforlitlighet = "hog" | "medel" | "lag"; export const TILLFORLITLIGHET_LABEL: Record = { - hog: "🟢 Hög", - medel: "🟡 Medel", - lag: "🔴 Låg", + hog: "Hög", + medel: "Medel", + lag: "Låg", }; export type TidKategori = diff --git a/src/felsokning/ecm.ts b/src/felsokning/ecm.ts index 3111fd9..036a33f 100644 --- a/src/felsokning/ecm.ts +++ b/src/felsokning/ecm.ts @@ -591,7 +591,7 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] { rubrik: "Hypoteser redovisas som ej verifierade", ok: true, kravs: false, - detalj: hypoteser > 0 ? `${hypoteser} hypotes(er) markeras 🔴 i rapporten — aldrig som konstaterade fel.` : undefined, + detalj: hypoteser > 0 ? `${hypoteser} hypotes(er) markeras som ej verifierade i rapporten — aldrig som konstaterade fel.` : undefined, }); rader.push({ diff --git a/src/felsokning/ikoner.tsx b/src/felsokning/ikoner.tsx new file mode 100644 index 0000000..1eee895 --- /dev/null +++ b/src/felsokning/ikoner.tsx @@ -0,0 +1,132 @@ +// Egen ikongrafik — enkla industriella linjeikoner (stroke: currentColor) +// i stället för emojis. Håller hela gränssnittet i samma dämpade, +// professionella ton oavsett plattform och typsnitt. + +import type { ReactNode } from "react"; + +function Ikon({ children, storlek = 14 }: { children: ReactNode; storlek?: number }) { + return ( + + ); +} + +export function IkonKamera({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +export function IkonSok({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +export function IkonLank({ storlek }: { storlek?: number }) { + return ( + + + + + + ); +} + +export function IkonDiagram({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +export function IkonKugghjul({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +export function IkonCheck({ storlek }: { storlek?: number }) { + return ( + + + + ); +} + +export function IkonKryss({ storlek }: { storlek?: number }) { + return ( + + + + ); +} + +export function IkonVarning({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +export function IkonMik({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +export function IkonUppdatera({ storlek }: { storlek?: number }) { + return ( + + + + + + ); +} + +export function IkonKlocka({ storlek }: { storlek?: number }) { + return ( + + + + + ); +} + +// Statuspunkt (ersätter 🟢🟡🔴): fylld cirkel i angiven färg. +export function IkonPunkt({ farg, storlek = 10 }: { farg: string; storlek?: number }) { + return ( + + ); +} diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index d674d86..c178111 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -152,6 +152,29 @@ export async function hamtaOversikt(): Promise { return ((await res.json()) as { arenden: OversiktsRad[] }).arenden; } +// Fordonshistorik: organisationens tidigare ärenden på samma objekt. +export interface FordonshistorikRad { + id: string; + nummer: number; + skapad: string; + avslutat: boolean; + felbeskrivning: string | null; + felorsaker: { avvikelse: string; orsaker: string[]; atgard: string }[]; +} + +export async function hamtaFordonshistorik(identifierare: string): Promise { + const res = await plattformFetch(`/api/fordon/${encodeURIComponent(identifierare)}/historik`); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return ((await res.json()) as { arenden: FordonshistorikRad[] }).arenden; +} + +// Flottdata: felorsaksstatistik per orsakskategori (arbetsledare/admin). +export async function hamtaFelorsaksstatistik(): Promise<{ orsak: string; antal: number }[]> { + const res = await plattformFetch("/api/statistik/felorsaker"); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return ((await res.json()) as { orsaker: { orsak: string; antal: number }[] }).orsaker; +} + // Autentiserat anrop mot plattformen. En utgången token rensas (401) så // att appen faller tillbaka till lokalt läge tills nästa inloggning. export async function plattformFetch(vag: string, init?: RequestInit): Promise { diff --git a/src/felsokning/projektioner.ts b/src/felsokning/projektioner.ts index c885cce..8c8744d 100644 --- a/src/felsokning/projektioner.ts +++ b/src/felsokning/projektioner.ts @@ -77,6 +77,48 @@ export function arAvslutat(arende: Arende): boolean { return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat"); } +// Fordonshistorik: tidigare ärenden på samma objekt (regnr/VIN) med deras +// dokumenterade felorsaker — grunden för pre-diagnostikens historiksteg +// och orsakskedjan. Lokal variant; i plattformsläge svarar servern på +// samma fråga över hela organisationen. +export interface HistorikArende { + id: string; + nummer: number; + skapad: string; + avslutat: boolean; + felbeskrivning?: string; + felorsaker: { avvikelse: string; orsaker: string[]; atgard: string }[]; +} + +export function lokalFordonshistorik( + arenden: Record, + identifierare: string, + exkluderaId?: string, +): HistorikArende[] { + const ident = identifierare.trim().toUpperCase(); + if (!ident) return []; + const resultat: HistorikArende[] = []; + for (const arende of Object.values(arenden)) { + if (arende.id === exkluderaId) continue; + const o = objekt(arende); + if (!o || o.identifierare.toUpperCase() !== ident) continue; + const felorsaker: HistorikArende["felorsaker"] = []; + for (const post of arende.handelser) { + const h = post.handelse; + if (h.typ === "felorsak") felorsaker.push({ avvikelse: h.avvikelse, orsaker: h.orsaker, atgard: h.atgard }); + } + resultat.push({ + id: arende.id, + nummer: arende.nummer, + skapad: arende.skapad, + avslutat: arAvslutat(arende), + felbeskrivning: felbeskrivning(arende), + felorsaker, + }); + } + return resultat.sort((a, b) => b.skapad.localeCompare(a.skapad)).slice(0, 20); +} + export interface UtfordKontroll { text: string; resultat?: string; @@ -269,7 +311,7 @@ export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string): rader.push("Utförda kontroller:"); if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)"); for (const k of b.utfordaKontroller) - rader.push(k.undantag ? ` ⚠ ${k.text} — underlag saknas: ${k.undantag}` : ` ✓ ${k.text}${k.resultat ? ` — ${k.resultat}` : ""}`); + rader.push(k.undantag ? ` ! ${k.text} — underlag saknas: ${k.undantag}` : ` ✓ ${k.text}${k.resultat ? ` — ${k.resultat}` : ""}`); rader.push(""); rader.push("Observationer:"); if (b.observationer.length === 0) rader.push(" (inga ännu)"); diff --git a/src/felsokning/ui.tsx b/src/felsokning/ui.tsx index 41419ee..b0438e0 100644 --- a/src/felsokning/ui.tsx +++ b/src/felsokning/ui.tsx @@ -10,6 +10,7 @@ import { useRef, type ReactNode } from "react"; import type { Tillforlitlighet } from "./domain"; import { TILLFORLITLIGHET_LABEL } from "./domain"; import { MikrofonKnapp } from "./Mikrofon"; +import { IkonPunkt } from "./ikoner"; // Industriell palett (inspirerad av tyska verkstadssystem): // bakgrund #ECECEC · paneler #F7F7F7 · kanter #C6C6C6 @@ -109,8 +110,19 @@ export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNo ); } +const NIVA_FARG: Record = { + hog: "#1E6B34", + medel: "#C08A00", + lag: "#8B1A1A", +}; + export function NivaBadge({ niva }: { niva: Tillforlitlighet }) { - return {TILLFORLITLIGHET_LABEL[niva]}; + return ( + + + {TILLFORLITLIGHET_LABEL[niva]} + + ); } export function TextFalt({ diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index b4f9e7c..629b2d7 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -11,11 +11,13 @@ import { felbeskrivning, formateraTid, foton, + lokalFordonshistorik, objekt, overlamningstext, sistaAktivitet, tidsfordelning, tidsfordelningsRader, + type HistorikArende, } from "@/felsokning/projektioner"; import { metodikForArende, useFelsokning } from "@/felsokning/store"; import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk"; @@ -56,7 +58,8 @@ import { underlagFinns, } from "@/felsokning/ecm"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; -import { skalaNerFoto, tidKlockslag } from "@/felsokning/format"; +import { skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format"; +import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner"; const FLIKAR = [ { id: "guide", label: "Guide" }, @@ -290,7 +293,7 @@ function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; s /> {!foto && !laser && ( filRef.current?.click()}> - 📷 Fotografera instrumentpanelen + Fotografera instrumentpanelen )} {laser &&

Systemet läser av mätarställningen …

} @@ -320,6 +323,79 @@ function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; s ); } +// Fordonshistorik i pre-diagnostiken: tidigare ärenden på samma objekt +// hämtas automatiskt (plattformen i inloggat läge, lokala storen annars) +// med deras dokumenterade felorsaker. Teknikern kan koppla orsakskedjan +// till det aktuella ärendet med ett tryck. +function TidigareArenden({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) { + const arenden = useFelsokning((s) => s.arenden); + const o = objekt(arende); + const [rader, setRader] = useState(() => + o ? lokalFordonshistorik(arenden, o.identifierare, arende.id) : [], + ); + const identifierare = o?.identifierare; + + useEffect(() => { + if (!identifierare) return; + let aktiv = true; + (async () => { + const { plattformAktiv: aktivPlattform, plattformToken, hamtaFordonshistorik } = await import("@/felsokning/plattform"); + if (!aktivPlattform() || !plattformToken()) return; + try { + const svar = await hamtaFordonshistorik(identifierare); + if (aktiv) { + setRader( + svar + .filter((r) => r.id !== arende.id) + .map((r) => ({ ...r, felbeskrivning: r.felbeskrivning ?? undefined, felorsaker: r.felorsaker ?? [] })), + ); + } + } catch { + // Lokal historik gäller. + } + })(); + return () => { + aktiv = false; + }; + }, [identifierare, arende.id]); + + if (rader.length === 0) return null; + return ( +
+

+ Tidigare ärenden på {identifierare} — granska innan historiken kvitteras +

+ {rader.map((rad) => ( +
+

+ Ärende #{rad.nummer} · {tidDatum(rad.skapad)} ·{" "} + {rad.avslutat ? "avslutat" : "pågående"} + {rad.felbeskrivning && — ”{rad.felbeskrivning}”} +

+ {rad.felorsaker.map((f, i) => ( +

+ Felorsak: {f.avvikelse} ({f.orsaker.join(", ")}) +

+ ))} + +
+ ))} +
+ ); +} + // Pre-Diagnostic Validation: ingen felsökning påbörjas förrän // grundkontrollerna är genomförda — eller dokumenterat motiverade. function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) { @@ -334,8 +410,8 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha {rader.map((r) => (

- {r.klar ? "✅" : "☐"} {r.rubrik} - {r.varning && ⚠ {r.varning}} + {r.klar ? : "☐"} {r.rubrik} + {r.varning && {r.varning}}

))} @@ -344,6 +420,7 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha

Har fordonets historik kontrollerats? (tidigare arbeten, återkommande fel, TSB, kampanjer)

+
setHistorikVal("ja")}> Ja — kontrollerad @@ -1038,7 +1115,7 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) }} /> - filRef.current?.click()}>📷 Ta foto — verifierar kontrollen + filRef.current?.click()}> Ta foto — verifierar kontrollen

Denna kontroll verifieras med foto.

) : ( @@ -1134,7 +1211,7 @@ const DOKTYPER = [ { id: "observation", label: "Observation" }, { id: "matvarde", label: "Mätvärde" }, { id: "foto", label: "Foto" }, - { id: "instrument", label: "📷 Instrument" }, + { id: "instrument", label: "Instrument" }, { id: "hypotes", label: "Hypotes" }, { id: "kommentar", label: "Kommentar" }, ] as const; @@ -1305,7 +1382,7 @@ function SnabbDokumentation({
{typ === "hypotes" && (

- 🔴 En hypotes är en möjlig felorsak som kräver verifiering — den loggas aldrig som ett konstaterat fel. + En hypotes är en möjlig felorsak som kräver verifiering — den loggas aldrig som ett konstaterat fel.

)} {aiLage === "vilar" && ( - ✨ Komplettera analysen: risker & osäkerheter + Komplettera analysen: risker & osäkerheter )} {aiLage === "arbetar" &&

Systemet sammanfattar …

} @@ -1439,7 +1516,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han uppdatera(); }} > - 🔗 Delningslänkar (kund/partner/intern) + Delningslänkar (kund/partner/intern) ); } @@ -1579,7 +1656,7 @@ function BriefFlik({
)} - 🔍 Granska underlaget + Granska underlaget )} @@ -1603,7 +1680,7 @@ function BriefFlik({ {b.utfordaKontroller.map((k, i) => k.undantag ? (

- ⚠ {k.text} — underlag saknas: {k.undantag} + {k.text} — underlag saknas: {k.undantag}

) : (

@@ -1724,7 +1801,7 @@ function RapportFlik({

- {rad.ok ? "✅" : rad.kravs ? "❌" : "⚠️"} + {rad.ok ? : rad.kravs ? : } {" "} {rad.rubrik} {!rad.kravs && (rekommenderas)} @@ -1734,7 +1811,7 @@ function RapportFlik({ ))} {!godkand && (

- Rapporten kan inte genereras förrän varje ❌ har evidens eller ett dokumenterat undantag + Rapporten kan inte genereras förrän varje underkänd rad har evidens eller ett dokumenterat undantag (”Underlag kan inte tas fram” i guiden).

)} @@ -1753,7 +1830,7 @@ function RapportFlik({
- 🟢 Öppna Live Share-vy + Öppna Live Share-vy {plattformAktiv() ? ( @@ -1767,7 +1844,7 @@ function RapportFlik({ skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." }); }} > - 🔗 Kopiera delningslänk + Kopiera delningslänk
) )} diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index fc586cd..9c72cd7 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -16,6 +16,7 @@ import { type PlattformRoll, } from "@/felsokning/plattform"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; +import { IkonDiagram, IkonKugghjul } from "@/felsokning/ikoner"; import { tidDatum, tidKlockslag } from "@/felsokning/format"; type Filter = "alla" | "pagaende" | "klara"; @@ -59,12 +60,12 @@ function PlattformInloggning() {
{(konto.roll === "arbetsledare" || konto.roll === "admin") && ( - 📊 Organisationsöversikt + Organisationsöversikt )} {konto.roll === "admin" && ( - ⚙️ Inställningar för organisationen + Inställningar för organisationen )}
@@ -313,7 +314,7 @@ export default function Arendelista() { {!plattformKonto() && ( - ⚙️ Inställningar + Inställningar )} diff --git a/src/pages/felsokning/NyttArende.tsx b/src/pages/felsokning/NyttArende.tsx index 0389b4d..78c1fc7 100644 --- a/src/pages/felsokning/NyttArende.tsx +++ b/src/pages/felsokning/NyttArende.tsx @@ -8,6 +8,7 @@ import { byggDemoTolkning } from "@/felsokning/demo"; import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { skalaNerFoto } from "@/felsokning/format"; +import { IkonKamera } from "@/felsokning/ikoner"; // Ärendestart: teknikern är redan känd (inloggning/namn) och ska kunna // starta ett ärende på under 15 sekunder. Primärvägen är att fotografera @@ -137,7 +138,7 @@ export default function NyttArende() { {skannar ? (

Tolkar dokumentet …

) : ( - skanRef.current?.click()}>📷 Skanna arbetsorder + skanRef.current?.click()}> Skanna arbetsorder )} {skanningsFel &&

{skanningsFel}

} @@ -273,9 +274,9 @@ export default function NyttArende() { // Visuell granskning: den skannade arbetsordern till vänster, tolkade // fält till höger. Konfidensen styr vad teknikern behöver göra: -// 🟢 ≥ 95 % godkänns automatiskt -// 🟡 80–95 % markerad för genomläsning -// 🔴 < 80 % kräver aktiv bekräftelse (eller redigering) +// Grön ≥ 95 % godkänns automatiskt +// Gul 80–95 % markerad för genomläsning +// Röd < 80 % kräver aktiv bekräftelse (eller redigering) // Klick på ett fält markerar var i dokumentet värdet hittades. function GranskaTolkning({ foto, diff --git a/src/pages/felsokning/Oversikt.tsx b/src/pages/felsokning/Oversikt.tsx index 6fa51c7..90626c9 100644 --- a/src/pages/felsokning/Oversikt.tsx +++ b/src/pages/felsokning/Oversikt.tsx @@ -8,6 +8,7 @@ import { useNavigate } from "react-router-dom"; import type { Arende } from "@/felsokning/domain"; import { hamtaAnvandare, + hamtaFelorsaksstatistik, hamtaOversikt, plattformAktiv, plattformFetch, @@ -82,6 +83,7 @@ export default function Oversikt() { const navigate = useNavigate(); const { arenden, laggInArende, sammanfoga } = useFelsokning(); const [rader, setRader] = useState(null); + const [felorsaker, setFelorsaker] = useState<{ orsak: string; antal: number }[]>([]); const [fel, setFel] = useState(""); const konto = plattformKonto(); const behorig = plattformAktiv() && (konto?.roll === "arbetsledare" || konto?.roll === "admin"); @@ -91,6 +93,8 @@ export default function Oversikt() { hamtaOversikt() .then(setRader) .catch(() => setFel("Kunde inte hämta översikten — kontrollera inloggningen.")); + // Flottdata: återkommande felorsaker över organisationen. + hamtaFelorsaksstatistik().then(setFelorsaker).catch(() => setFelorsaker([])); }, [behorig]); if (!behorig) { @@ -178,6 +182,24 @@ export default function Oversikt() { )} + {felorsaker.length > 0 && ( + + {felorsaker.map((rad) => ( +
+ {rad.orsak} + + {rad.antal} +
+ ))} +

+ Ur dokumenterade felorsaksanalyser — visar mönster som slitage, tidigare reparationer eller möjliga konstruktionsproblem. +

+
+ )} + {rader.length === 0 &&

Inga ärenden i organisationen ännu.

} {rader.map((rad) => (