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:
@@ -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