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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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"],
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 på {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 & osäkerheter
|
||||
Komplettera analysen: risker & 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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
// 🟡 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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user