Fordonshistorik med orsakskedja, felorsaksstatistik och egen ikongrafik

Fordonshistoriken är nu verklig i stället för självrapporterad:

- GET /api/fordon/{identifierare}/historik: organisationens tidigare
  ärenden på samma objekt (regnr/VIN, case-okänsligt) med dokumenterade
  felorsaker — organisationsgränsen gäller alltid
- Pre-diagnostikens historiksteg visar tidigare ärenden automatiskt
  (servern i inloggat läge, lokala storen offline) och orsakskedjan
  kopplas med ett tryck: "kopplat till tidigare ärende #N — …"
- GET /api/statistik/felorsaker (arbetsledare/admin): flottdata —
  orsakskategorierna ur alla felorsaksanalyser aggregerade per
  organisation, visade som stapelöversikt i arbetsledarvyn

Egen ikongrafik i stället för emojis (src/felsokning/ikoner.tsx):
enkla industriella linjeikoner i SVG (kamera, förstoringsglas, länk,
diagram, kugghjul, bock, kryss, varning, mikrofon, uppdatera, klocka)
och färgpunkter för tillförlitlighets- och statusnivåer. Etiketterna
är ren text ("Hög/Medel/Låg", "Hypotes (ej verifierad)") — maskinellt
verifierat emojifritt i klicktestet.

58 vitest-tester, integrationstest (historik, isolering, statistikens
behörighet) och OpenAPI-validering gröna.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 09:50:01 +00:00
parent a37cc027ae
commit 09d3558e0c
20 changed files with 524 additions and 43 deletions
+3 -2
View File
@@ -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, 🟡 8095 % 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å E0E6, 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 (1115 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
+12 -7
View File
@@ -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)
+23
View File
@@ -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"
+59
View File
@@ -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]
+53
View File
@@ -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.
+5 -4
View File
@@ -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"}
</span>
}
>
@@ -93,11 +94,11 @@ export function DelatArendeVy({
<Panel rubrik="Aktuell status">
{b.utfordaKontroller.map((k, i) => (
<p key={`u${i}`} className="py-0.5 text-[14px]"> {k.text}</p>
<p key={`u${i}`} className="py-0.5 text-[14px]"><span className="text-[#1E6B34]"><IkonCheck /></span> {k.text}</p>
))}
{!avslutat && pagaende && <p className="py-0.5 text-[14px] text-[#00437A]">🔄 {pagaende}</p>}
{!avslutat && pagaende && <p className="py-0.5 text-[14px] text-[#00437A]"><IkonUppdatera /> {pagaende}</p>}
{b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => (
<p key={`e${i}`} className="py-0.5 text-[14px] text-[#4A5560]"> {e}</p>
<p key={`e${i}`} className="py-0.5 text-[14px] text-[#4A5560]"><IkonKlocka /> {e}</p>
))}
</Panel>
+2 -1
View File
@@ -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 ? "■" : <IkonMik />}
</button>
{lyssnar && (
<span className="text-[12px] font-semibold text-[#8B1A1A]">
+2
View File
@@ -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"],
];
+26
View File
@@ -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);
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendatio
export const AI_RADTYP_LABEL: Record<AiRadTyp, string> = {
observation: "Observation",
verifierat: "Verifierat",
hypotes: "🔴 Hypotes",
hypotes: "Hypotes (ej verifierad)",
rekommendation: "Rekommendation",
};
+3 -3
View File
@@ -5,9 +5,9 @@
export type Tillforlitlighet = "hog" | "medel" | "lag";
export const TILLFORLITLIGHET_LABEL: Record<Tillforlitlighet, string> = {
hog: "🟢 Hög",
medel: "🟡 Medel",
lag: "🔴 Låg",
hog: "Hög",
medel: "Medel",
lag: "Låg",
};
export type TidKategori =
+1 -1
View File
@@ -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({
+132
View File
@@ -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 (
<svg
width={storlek}
height={storlek}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="inline-block align-[-2px]"
aria-hidden="true"
>
{children}
</svg>
);
}
export function IkonKamera({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" />
<circle cx="12" cy="13" r="3.5" />
</Ikon>
);
}
export function IkonSok({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M15.5 15.5 21 21" />
</Ikon>
);
}
export function IkonLank({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M9.5 14.5 14.5 9.5" />
<path d="M8 16l-2.5 2.5a3.5 3.5 0 0 1-5-5v0L3 11" transform="translate(3 -1)" />
<path d="M13 5l2.5-2.5a3.5 3.5 0 0 1 5 5L18 10" transform="translate(0 2)" />
</Ikon>
);
}
export function IkonDiagram({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M3 3v18h18" />
<path d="M7 15v3M12 10v8M17 6v12" />
</Ikon>
);
}
export function IkonKugghjul({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1" />
</Ikon>
);
}
export function IkonCheck({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M4 12.5 9.5 18 20 6" />
</Ikon>
);
}
export function IkonKryss({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M6 6l12 12M18 6 6 18" />
</Ikon>
);
}
export function IkonVarning({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M12 3 2.5 20h19L12 3z" />
<path d="M12 9.5V14M12 16.8v.2" />
</Ikon>
);
}
export function IkonMik({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3" />
</Ikon>
);
}
export function IkonUppdatera({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M20 5v5h-5" />
<path d="M4 19v-5h5" />
<path d="M19.5 10a8 8 0 0 0-14-3.5M4.5 14a8 8 0 0 0 14 3.5" />
</Ikon>
);
}
export function IkonKlocka({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3.5 2" />
</Ikon>
);
}
// Statuspunkt (ersätter 🟢🟡🔴): fylld cirkel i angiven färg.
export function IkonPunkt({ farg, storlek = 10 }: { farg: string; storlek?: number }) {
return (
<svg width={storlek} height={storlek} viewBox="0 0 10 10" className="inline-block" aria-hidden="true">
<circle cx="5" cy="5" r="4.5" fill={farg} />
</svg>
);
}
+23
View File
@@ -152,6 +152,29 @@ export async function hamtaOversikt(): Promise<OversiktsRad[]> {
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<FordonshistorikRad[]> {
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<Response> {
+43 -1
View File
@@ -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<string, Arende>,
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)");
+13 -1
View File
@@ -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<Tillforlitlighet, string> = {
hog: "#1E6B34",
medel: "#C08A00",
lag: "#8B1A1A",
};
export function NivaBadge({ niva }: { niva: Tillforlitlighet }) {
return <span className="whitespace-nowrap text-[12px] font-semibold">{TILLFORLITLIGHET_LABEL[niva]}</span>;
return (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-[12px] font-semibold">
<IkonPunkt farg={NIVA_FARG[niva]} />
{TILLFORLITLIGHET_LABEL[niva]}
</span>
);
}
export function TextFalt({
+92 -15
View File
@@ -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 && (
<StorKnapp variant="sekundar" onClick={() => filRef.current?.click()}>
📷 Fotografera instrumentpanelen
<IkonKamera /> Fotografera instrumentpanelen
</StorKnapp>
)}
{laser && <p className="animate-pulse py-1 text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av mätarställningen </p>}
@@ -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<HistorikArende[]>(() =>
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 (
<div className="mb-2 rounded border border-[#C6C6C6] bg-white p-2">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
Tidigare ärenden {identifierare} granska innan historiken kvitteras
</p>
{rader.map((rad) => (
<div key={rad.id} className="border-b border-[#EBEBEB] py-1.5 text-[12px] last:border-0">
<p>
<span className="font-semibold">Ärende #{rad.nummer}</span> · {tidDatum(rad.skapad)} ·{" "}
{rad.avslutat ? "avslutat" : "pågående"}
{rad.felbeskrivning && <span className="text-[#4A5560]"> {rad.felbeskrivning}</span>}
</p>
{rad.felorsaker.map((f, i) => (
<p key={i} className="pl-3 text-[#4A5560]">
Felorsak: {f.avvikelse} ({f.orsaker.join(", ")})
</p>
))}
<button
className="mt-1 text-[11px] font-semibold text-[#00437A] underline-offset-2 hover:underline"
onClick={() =>
skicka({
typ: "kommentar",
text: `Orsakskedja: kopplat till tidigare ärende #${rad.nummer}${
rad.felorsaker[0] ? `${rad.felorsaker[0].avvikelse}` : rad.felbeskrivning ? `${rad.felbeskrivning}` : ""
}`,
})
}
>
+ Koppla till orsakskedjan
</button>
</div>
))}
</div>
);
}
// 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
<Panel rubrik="Pre-diagnostik — innan felsökningen börjar">
{rader.map((r) => (
<p key={r.id} className="py-0.5 text-[13px]">
<span className={r.klar ? "text-[#1E6B34]" : "text-[#8B1A1A]"}>{r.klar ? "✅" : "☐"}</span> {r.rubrik}
{r.varning && <span className="ml-1 text-[11px] font-semibold text-[#9A6700]"> {r.varning}</span>}
<span className={r.klar ? "text-[#1E6B34]" : "text-[#8B1A1A]"}>{r.klar ? <IkonCheck /> : "☐"}</span> {r.rubrik}
{r.varning && <span className="ml-1 text-[11px] font-semibold text-[#9A6700]"><IkonVarning /> {r.varning}</span>}
</p>
))}
@@ -344,6 +420,7 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har fordonets historik kontrollerats? (tidigare arbeten, återkommande fel, TSB, kampanjer)
</p>
<TidigareArenden arende={arende} skicka={skicka} />
<div className="mb-2 grid grid-cols-2 gap-2">
<StorKnapp variant={historikVal === "ja" ? "primar" : "sekundar"} onClick={() => setHistorikVal("ja")}>
Ja kontrollerad
@@ -1038,7 +1115,7 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
}}
/>
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
<StorKnapp onClick={() => filRef.current?.click()}>📷 Ta foto verifierar kontrollen</StorKnapp>
<StorKnapp onClick={() => filRef.current?.click()}><IkonKamera /> Ta foto verifierar kontrollen</StorKnapp>
<p className="mt-2 text-[12px] text-[#707070]">Denna kontroll verifieras med foto.</p>
</>
) : (
@@ -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({
<div className="mt-3">
{typ === "hypotes" && (
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
🔴 En hypotes är en möjlig felorsak som kräver verifiering den loggas aldrig som ett konstaterat fel.
<IkonPunkt farg="#8B1A1A" /> En hypotes är en möjlig felorsak som kräver verifiering den loggas aldrig som ett konstaterat fel.
</p>
)}
<TextFalt
@@ -1371,7 +1448,7 @@ function OverlamningDialog({
</pre>
{aiLage === "vilar" && (
<StorKnapp variant="sekundar" className="mb-3" onClick={komplettera}>
Komplettera analysen: risker &amp; osäkerheter
Komplettera analysen: risker &amp; osäkerheter
</StorKnapp>
)}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">Systemet sammanfattar </p>}
@@ -1439,7 +1516,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
uppdatera();
}}
>
🔗 Delningslänkar (kund/partner/intern)
<IkonLank /> Delningslänkar (kund/partner/intern)
</StorKnapp>
);
}
@@ -1579,7 +1656,7 @@ function BriefFlik({
</div>
)}
<StorKnapp variant="sekundar" disabled={granskning === "arbetar"} onClick={granska}>
🔍 Granska underlaget
<IkonSok /> Granska underlaget
</StorKnapp>
</Panel>
)}
@@ -1603,7 +1680,7 @@ function BriefFlik({
{b.utfordaKontroller.map((k, i) =>
k.undantag ? (
<p key={i} className="py-0.5 text-[14px] text-[#9A6700]">
{k.text} underlag saknas: {k.undantag}
<IkonVarning /> {k.text} underlag saknas: {k.undantag}
</p>
) : (
<p key={i} className="py-0.5 text-[14px]">
@@ -1724,7 +1801,7 @@ function RapportFlik({
<div key={rad.id} className="border-b border-[#EBEBEB] py-1 last:border-0">
<p className="text-[13px]">
<span className={rad.ok ? "text-[#1E6B34]" : rad.kravs ? "text-[#8B1A1A]" : "text-[#9A6700]"}>
{rad.ok ? "✅" : rad.kravs ? "❌" : "⚠️"}
{rad.ok ? <IkonCheck /> : rad.kravs ? <IkonKryss /> : <IkonVarning />}
</span>{" "}
{rad.rubrik}
{!rad.kravs && <span className="text-[11px] text-[#707070]"> (rekommenderas)</span>}
@@ -1734,7 +1811,7 @@ function RapportFlik({
))}
{!godkand && (
<p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">
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).
</p>
)}
@@ -1753,7 +1830,7 @@ function RapportFlik({
</StorKnapp>
</div>
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
<StorKnapp variant="sekundar"><IkonPunkt farg="#1E6B34" /> Öppna Live Share-vy</StorKnapp>
</Link>
{plattformAktiv() ? (
<DelningsHanterare arende={arende} skicka={skicka} />
@@ -1767,7 +1844,7 @@ function RapportFlik({
skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." });
}}
>
🔗 Kopiera delningslänk
<IkonLank /> Kopiera delningslänk
</StorKnapp>
)
)}
+4 -3
View File
@@ -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() {
</div>
{(konto.roll === "arbetsledare" || konto.roll === "admin") && (
<Link to="/felsokning/oversikt" className="mt-3 block">
<StorKnapp variant="sekundar">📊 Organisationsöversikt</StorKnapp>
<StorKnapp variant="sekundar"><IkonDiagram /> Organisationsöversikt</StorKnapp>
</Link>
)}
{konto.roll === "admin" && (
<Link to="/felsokning/installningar" className="mt-2 block">
<StorKnapp variant="sekundar"> Inställningar för organisationen</StorKnapp>
<StorKnapp variant="sekundar"><IkonKugghjul /> Inställningar för organisationen</StorKnapp>
</Link>
)}
</Panel>
@@ -313,7 +314,7 @@ export default function Arendelista() {
{!plattformKonto() && (
<Link to="/felsokning/installningar" className="mb-4 block">
<StorKnapp variant="sekundar"> Inställningar</StorKnapp>
<StorKnapp variant="sekundar"><IkonKugghjul /> Inställningar</StorKnapp>
</Link>
)}
+5 -4
View File
@@ -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 ? (
<p className="animate-pulse py-2 text-center font-semibold text-[#00437A]">Tolkar dokumentet </p>
) : (
<StorKnapp onClick={() => skanRef.current?.click()}>📷 Skanna arbetsorder</StorKnapp>
<StorKnapp onClick={() => skanRef.current?.click()}><IkonKamera /> Skanna arbetsorder</StorKnapp>
)}
{skanningsFel && <p className="mt-2 font-semibold text-[#8B1A1A]">{skanningsFel}</p>}
</Panel>
@@ -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
// 🟡 8095 % markerad för genomläsning
// 🔴 < 80 % kräver aktiv bekräftelse (eller redigering)
// Grön ≥ 95 % godkänns automatiskt
// Gul 8095 % 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,
+22
View File
@@ -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<OversiktsRad[] | null>(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() {
)}
</Panel>
{felorsaker.length > 0 && (
<Panel rubrik="Felorsaksstatistik — återkommande fel i organisationen">
{felorsaker.map((rad) => (
<div key={rad.orsak} className="flex items-center gap-2 py-0.5 text-[13px]">
<span className="w-52 shrink-0 text-[#333333]">{rad.orsak}</span>
<span
className="h-3 rounded-sm bg-[#00437A]"
style={{ width: `${Math.min(100, (rad.antal / felorsaker[0].antal) * 100)}%`, minWidth: 6 }}
/>
<span className="font-semibold">{rad.antal}</span>
</div>
))}
<p className="mt-2 text-[11px] text-[#707070]">
Ur dokumenterade felorsaksanalyser visar mönster som slitage, tidigare reparationer eller möjliga konstruktionsproblem.
</p>
</Panel>
)}
{rader.length === 0 && <p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i organisationen ännu.</p>}
{rader.map((rad) => (