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
+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) => (