Diagnostikgränssnittet på engelska

Portalen var engelsk, men själva arbetsverktyget — det tekniker faktiskt
använder — var svenskt rakt igenom. "Engelska som standard" gällde alltså
skyltfönstret och inte produkten.

Omkring 400 användarsynliga strängar är översatta: etiketter, knappar,
frågor, felmeddelanden, platshållare och de härledda projektionerna
(brief, tidrapport, kundrapport).

Bytet gjordes med en karta och ett skript som bara byter EXAKTA träffar
inom citattecken eller mellan JSX-taggar, aldrig delsträngar. Skriptet
rapporterar dessutom vilka poster som inte fick någon träff — fem
strängar stod som JSX-text efter ett element och syntes därför inte för
mönstret. Utan den rapporten hade de blivit kvar tysta.

Typsystemet fångade det som var farligt. Åtta anrop skickade händelsen
typ: "kommentar" och hade blivit typ: "comment" — en händelsetyp är DATA
i en append-only logg, inte text. Samma sak för `krav === "kommentar"`
och för ArbetsorderGrupp. Det är precis den sortens fel en
massöversättning gör, och precis därför den ska göras i ett projekt som
kompilerar.

Skillnaden mellan etikett och värde är genomgående kodad i domänmodellen
— nycklarna är `godkant`, `hog`, `symptomet_borta`, och det översatta är
_LABEL-posterna. Det var det som gjorde bytet möjligt att göra maskinellt
utan att röra loggen.

En regression från förra commiten är rättad: Slutsats.tsx sållade bort
hypotesbristerna med `text.includes("bemöts inte")`. När granskningen
började svara på engelska slutade jämförelsen tyst att matcha. Den går nu
på nyckeln, som inte byter språk.

Två saker att veta om befintliga installationer: en organisation som
sparat egna objekttyper eller identifieringsmetoder på svenska får
standardlistan tillbaka, eftersom okända värden filtreras bort, och
streckkodsläsarens `typ` matchar nu de engelska metodnamnen. Båda
degraderar till standard i stället för att gå sönder.

Datumen står kvar i ISO-format (sv-SE ger YYYY-MM-DD). Det är inte en
kvarlämnad svenskhet utan det entydiga formatet — en verkstadsrapport
ska inte kunna läsas som 03/04 åt två håll.

Kvar: ecm.ts (regelverkets rådtexter) och metodiker.mjs (metodikernas
steg och kontroller). De tas härnäst.

602 tester, typkontroll och lint rena.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 17:34:27 +00:00
parent b2c2ce72a3
commit 3ea14575f1
23 changed files with 504 additions and 496 deletions
@@ -46,10 +46,10 @@ export function Bild({
delningskod?: string;
}) {
const kalla = useKalla(bilaga, delningskod);
if (kalla === "laddar") return <Platshallare text="Hämtar bild …" />;
if (kalla === "laddar") return <Platshallare text="Retrieving image …" />;
// Ärligt om att bilden inte gick att hämta, i stället för en trasig
// bildikon som lämnar teknikern i tvivel om vad som dokumenterats.
if (!kalla) return <Platshallare text="Bilden kunde inte hämtas." />;
if (!kalla) return <Platshallare text="The image could not be retrieved." />;
return <img src={kalla} alt={alt} className={className} />;
}
@@ -63,7 +63,7 @@ export function Klipp({
delningskod?: string;
}) {
const kalla = useKalla(bilaga, delningskod);
if (kalla === "laddar") return <Platshallare text="Hämtar videoklipp …" />;
if (!kalla) return <Platshallare text="Videoklippet kunde inte hämtas." />;
if (kalla === "laddar") return <Platshallare text="Retrieving video …" />;
if (!kalla) return <Platshallare text="The video could not be retrieved." />;
return <video src={kalla} controls className={className} />;
}
+24 -24
View File
@@ -44,7 +44,7 @@ function BeslutsKnappar({
<>
<label className="mb-2 block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
{val === "avbojt" ? "Kort motivering (valfritt)" : "Kommentar (valfritt)"}
{val === "avbojt" ? "Short rationale (optional)" : "Comment (optional)"}
</span>
<input
value={kommentar}
@@ -64,7 +64,7 @@ function BeslutsKnappar({
setSkickar(false);
}}
>
{skickar ? "Skickar …" : "Skicka besked"}
{skickar ? "Sending …" : "Send decision"}
</StorKnapp>
<p className="mt-1 text-[11px] text-[#707070]">
Beskedet registreras i ärendet och kan inte ändras här kontakta verkstaden om du ändrar dig.
@@ -78,14 +78,14 @@ function BeslutsKnappar({
function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) {
const idn = arendeidentitet(arende);
const falt: [string, string | undefined][] = [
["Arbetsorder", idn.arbetsorder],
["Work order", idn.arbetsorder],
["Claim", idn.claim],
["Skadenr", idn.skadenummer],
["Regnr", idn.identifierare],
["Claim no.", idn.skadenummer],
["Reg. no.", idn.identifierare],
["VIN", idn.vin],
["Miltal", idn.miltal],
["Ansvarig tekniker", idn.ansvarig],
["Status", avslutat ? "Avslutat" : "Felsökning pågår"],
["Mileage", idn.miltal],
["Responsible technician", idn.ansvarig],
["Status", avslutat ? "Closed" : "Diagnosis in progress"],
];
return (
<div className="sticky top-11 z-10 mb-3 border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 shadow-[0_1px_3px_rgba(0,0,0,0.1)] print:static">
@@ -142,14 +142,14 @@ export function DelatArendeVy({
return (
<FelsokningSkal
rubrik={b.objekt?.beskrivning ?? "Ärende"}
rubrik={b.objekt?.beskrivning ?? "Case"}
hoger={
<span
className={`px-3 py-1 text-[12px] font-semibold uppercase ${
avslutat ? "bg-[#3E5A78] text-white" : "bg-[#1E6B34] text-white"
}`}
>
{avslutat ? "Avslutat" : "Felsökning pågår"}
{avslutat ? "Closed" : "Diagnosis in progress"}
</span>
}
>
@@ -163,7 +163,7 @@ export function DelatArendeVy({
flottansvarig. De ska få bilden på fem sekunder och sedan kunna
gå djupare — inte tvärtom. Texten härleds ur det nivåfiltrerade
underlaget, så den kan aldrig avslöja något som nivån döljer. */}
<Panel rubrik="Sammanfattning">
<Panel rubrik="Summary">
<p className="text-[15px] leading-[24px] text-[#1B1E22]">{sammanfatta(arende).text}</p>
</Panel>
@@ -174,13 +174,13 @@ export function DelatArendeVy({
if (!post || post.handelse.typ !== "slutsats") return null;
const h = post.handelse;
const rader: [string, string][] = [
[h.orsakFastställd === false ? "Skäl till att orsaken inte fastställts" : "Motivering", h.motivering],
["Uteslutna alternativ", h.uteslutet],
...(h.atgardsval ? ([["Val av åtgärd", h.atgardsval]] as [string, string][]) : []),
["Kvarstående osäkerhet", h.kvarstaende],
[h.orsakFastställd === false ? "Reason the cause could not be established" : "Rationale", h.motivering],
["Dismissed alternatives", h.uteslutet],
...(h.atgardsval ? ([["Choice of action", h.atgardsval]] as [string, string][]) : []),
["Remaining uncertainty", h.kvarstaende],
];
return (
<Panel rubrik="Slutsats och motivering">
<Panel rubrik="Closing statement and rationale">
<dl className="text-[14px] leading-[22px]">
{rader.map(([etikett, text]) => (
<div key={etikett} className="border-t border-[#D7DCE2] py-2 first:border-t-0">
@@ -198,13 +198,13 @@ export function DelatArendeVy({
</p>
{b.felbeskrivning && (
<Panel rubrik="Kundens felbeskrivning">
<Panel rubrik="Customer's fault description">
<p className="text-[14px]">{b.felbeskrivning}</p>
</Panel>
)}
{forslag.length > 0 && (
<Panel rubrik="Åtgärdsförslag">
<Panel rubrik="Proposed action">
{forslag.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
@@ -232,7 +232,7 @@ export function DelatArendeVy({
</Panel>
)}
<Panel rubrik="Aktuell status">
<Panel rubrik="Current status">
{b.utfordaKontroller.map((k, i) => (
<p key={`u${i}`} className="py-0.5 text-[14px]"><span className="text-[#1E6B34]"><IkonCheck /></span> {k.text}</p>
))}
@@ -253,7 +253,7 @@ export function DelatArendeVy({
</Panel>
)}
{bilder.length > 0 && (
<Panel rubrik="Bilder">
<Panel rubrik="Images">
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
@@ -266,7 +266,7 @@ export function DelatArendeVy({
)}
{matvarden.length > 0 && (
<Panel rubrik="Mätvärden">
<Panel rubrik="Measurements">
<table className="w-full text-[14px]">
<tbody>
{matvarden.map((p) => {
@@ -287,7 +287,7 @@ export function DelatArendeVy({
</Panel>
)}
<Panel rubrik="Tidslinje">
<Panel rubrik="Timeline">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-[13px]">
<span className="font-mono font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>{" "}
@@ -297,12 +297,12 @@ export function DelatArendeVy({
</Panel>
{!avslutat && pagaende && (
<Panel rubrik="Rekommenderat nästa steg">
<Panel rubrik="Recommended next step">
<p className="text-[14px]">{pagaende}</p>
</Panel>
)}
<Panel rubrik="Arbetstid">
<Panel rubrik="Labour time">
<p className="text-[17px] font-semibold">{b.totalArbetstid}</p>
{tidsfordelningsRader(arende, nu).map((r) => (
<p key={r.label} className="text-[#333333]">
+1 -1
View File
@@ -47,7 +47,7 @@ export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) {
<button
type="button"
onClick={() => (lyssnar ? stoppa() : starta())}
aria-label={lyssnar ? "Stoppa inspelning" : "Starta röstinmatning"}
aria-label={lyssnar ? "Stop recording" : "Start voice input"}
className={`min-h-8 min-w-11 border px-3 text-[14px] font-semibold transition-colors ${
lyssnar
? "animate-pulse border-[#6E1414] bg-[#8B1A1A] text-white"
+25 -17
View File
@@ -25,35 +25,37 @@ import { FARG, Etikett, Knapp, Statusmärke } from "@/alva/komponenter";
interface Brist {
falt: string;
/** Katalognyckeln. Saknas för brister som bara har en färdig text. */
nyckel?: string;
text: string;
}
const FALT = [
{
id: "motivering" as const,
etikett: "Motivering",
fraga: "Varför följer slutsatsen av underlaget?",
hjalp: "Knyt mätvärden, foton och kontroller till slutsatsen. Vad i evidensen gör att just detta följer?",
etikett: "Rationale",
fraga: "Why does the conclusion follow from the evidence?",
hjalp: "Tie measurements, photographs and checks to the conclusion. What in the evidence makes this follow?",
rader: 4,
},
{
id: "uteslutet" as const,
etikett: "Uteslutna alternativ",
fraga: "Vad övervägdes, och varför föll det bort?",
hjalp: "En felsökning utan uteslutna alternativ är en gissning som råkade stämma.",
etikett: "Dismissed alternatives",
fraga: "What was considered, and why was it dismissed?",
hjalp: "A diagnosis without dismissed alternatives is a guess that happened to be right.",
rader: 3,
},
{
id: "atgardsval" as const,
etikett: "Val av åtgärd",
fraga: "Varför denna åtgärd och inte en annan?",
hjalp: "Byte eller justering? Hela enheten eller en del? Skälet läses av den som granskar kostnaden.",
etikett: "Choice of action",
fraga: "Why this action and not another?",
hjalp: "Replacement or adjustment? The whole unit or a part? The reason is read by whoever reviews the cost.",
rader: 2,
},
{
id: "kvarstaende" as const,
etikett: "Kvarstående osäkerhet",
fraga: "Vad är fortfarande osäkert?",
etikett: "Remaining uncertainty",
fraga: "What remains uncertain?",
hjalp: 'Får vara "inget" — men det måste sägas aktivt.',
rader: 2,
},
@@ -82,7 +84,13 @@ export function Slutsatspanel({ arende, skicka }: { arende: Arende; skicka: (h:
);
const obemötta: string[] = useMemo(() => obemottaHypoteser(handelser, förslag), [handelser, förslag]);
const brist = (falt: string) => brister.find((b) => b.falt === falt && !b.text.includes("bemöts inte"));
// Hypotesbristerna hör till fältet "uteslutet" men är inte en brist i
// fältet — de listas för sig nedan. De sållas därför bort här, på
// NYCKELN och inte på texten: texten är översatt och byter språk med
// organisationen, och en jämförelse mot en svensk fras slutade tyst
// att matcha när standardspråket blev engelska.
const brist = (falt: string) =>
brister.find((b) => b.falt === falt && b.nyckel !== "slutsats.hypotes_obemott");
const klar = brister.length === 0;
const arbeteUtfört = handelser.some((h) => h.typ === "atgard_utford" && h.utford);
@@ -115,8 +123,8 @@ export function Slutsatspanel({ arende, skicka }: { arende: Arende; skicka: (h:
Men varför den inte kunde det är fortfarande ett varför. */}
<div className="mb-4 flex flex-wrap gap-2">
{[
{ värde: true, text: "Orsak fastställd" },
{ värde: false, text: "Orsak kunde inte fastställas" },
{ värde: true, text: "Cause established" },
{ värde: false, text: "Cause could not be established" },
].map((val) => (
<button
key={String(val.värde)}
@@ -137,7 +145,7 @@ export function Slutsatspanel({ arende, skicka }: { arende: Arende; skicka: (h:
{obemötta.length > 0 && (
<div className="mb-4 border-2 px-4 py-2" style={{ borderColor: FARG.graphite }}>
<Etikett ton="graphite">Hypoteser i loggen som behöver bemötas</Etikett>
<Etikett ton="graphite">Hypotheses in the log that need addressing</Etikett>
<ul className="mt-2 text-[13px] leading-[20px]" style={{ color: FARG.graphite }}>
{obemötta.map((text) => (
<li key={text} className="flex gap-2">
@@ -154,7 +162,7 @@ export function Slutsatspanel({ arende, skicka }: { arende: Arende; skicka: (h:
{FALT.filter((f) => f.id !== "atgardsval" || arbeteUtfört).map((f) => {
const fel = brist(f.id);
const etikett = f.id === "motivering" && !fastställd ? "Skäl till att orsaken inte fastställts" : f.etikett;
const etikett = f.id === "motivering" && !fastställd ? "Reason the cause could not be established" : f.etikett;
return (
<div key={f.id} className="border-t py-2" style={{ borderColor: FARG.lightSteel }}>
<label
@@ -191,7 +199,7 @@ export function Slutsatspanel({ arende, skicka }: { arende: Arende; skicka: (h:
<div className="mt-4 flex flex-wrap items-center gap-4">
<Knapp disabled={!klar} onClick={() => skicka(förslag)}>
{befintlig ? "Uppdatera slutsats" : "Registrera slutsats"}
{befintlig ? "Update closing statement" : "Record closing statement"}
</Knapp>
{!klar && (
<span className="text-[12px]" style={{ color: FARG.steel }}>
@@ -192,7 +192,7 @@ describe("arbetsorderskanning", () => {
expect(falt.map((f) => f.id)).toEqual(["fordon_regnr", "fordon_vin"]);
expect(falt[0].konfidens).toBe(1);
expect(falt[0].varde).toBe("abc123");
expect(falt[0].grupp).toBe("Personbil");
expect(falt[0].grupp).toBe("Vehicle");
expect(falt[1].omrade?.x).toBe(0.5);
expect(() => normaliseraArbetsorder({})).toThrow();
});
@@ -17,10 +17,10 @@ describe("organisationsinställningar", () => {
it("okända värden filtreras bort och ordningen följer standardlistan", () => {
const inst = normalisera({
objekttyper: ["Hydraulik", "Påhittad typ", "Personbil"],
objekttyper: ["Hydraulics", "Påhittad typ", "Passenger car"],
identifieringsmetoder: ["VIN"],
});
expect(inst.objekttyper).toEqual(["Personbil", "Hydraulik"]);
expect(inst.objekttyper).toEqual(["Passenger car", "Hydraulics"]);
expect(inst.identifieringsmetoder).toEqual(["VIN"]);
});
@@ -33,10 +33,10 @@ describe("organisationsinställningar", () => {
it("sparade val på enheten läses tillbaka", () => {
localStorage.setItem(
"gf-installningar",
JSON.stringify({ objekttyper: ["Elsystem"], identifieringsmetoder: ["Serienummer", "Manuell inmatning"] }),
JSON.stringify({ objekttyper: ["Electrical system"], identifieringsmetoder: ["Serial number", "Manual entry"] }),
);
const inst = lastaInstallningar();
expect(inst.objekttyper).toEqual(["Elsystem"]);
expect(inst.identifieringsmetoder).toEqual(["Serienummer", "Manuell inmatning"]);
expect(inst.objekttyper).toEqual(["Electrical system"]);
expect(inst.identifieringsmetoder).toEqual(["Serial number", "Manual entry"]);
});
});
@@ -18,7 +18,7 @@ function byggArende(poster: [string, Handelse][]): Arende {
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: {
typ: "Personbil",
typ: "Passenger car",
identifierare: "ABC123",
identifieringsmetod: "Registreringsnummer",
beskrivning: "Volvo XC60 D4 2019",
@@ -192,7 +192,7 @@ describe("brief", () => {
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b.hypoteser).toEqual([{ text: "Obalans i höger framhjul", niva: "lag" }]);
expect(b.observationer).not.toContain("Obalans i höger framhjul");
expect(b.tillforlitlighet.some((r) => r.niva === "medel" && /inte verifierad/.test(r.text))).toBe(true);
expect(b.tillforlitlighet.some((r) => r.niva === "medel" && /not yet verified/i.test(r.text))).toBe(true);
});
it("är regenererbar: samma logg ger alltid samma brief", () => {
@@ -7,15 +7,15 @@ describe("avläst kod klassificeras innan den används", () => {
// Mellanslag och bindestreck i streckkoder normaliseras bort.
expect(tolkaKod(" yv1dz8256f2123456 ")?.typ).toBe("VIN");
// 16 tecken är inget VIN — men kan vara ett serienummer.
expect(tolkaKod("YV1DZ8256F212345")?.typ).toBe("Serienummer");
expect(tolkaKod("YV1DZ8256F212345")?.typ).toBe("Serial number");
// I/O/Q förekommer aldrig i VIN → klassas som serienummer.
expect(tolkaKod("YV1DZ8256F212345O")?.typ).toBe("Serienummer");
expect(tolkaKod("YV1DZ8256F212345O")?.typ).toBe("Serial number");
});
it("känner igen svenska registreringsnummer i båda serierna", () => {
expect(tolkaKod("ABC123")).toEqual({ varde: "ABC123", typ: "Regnr" });
expect(tolkaKod("abc12a")).toEqual({ varde: "ABC12A", typ: "Regnr" });
expect(tolkaKod("ABC 123")?.typ).toBe("Regnr");
expect(tolkaKod("ABC123")).toEqual({ varde: "ABC123", typ: "Reg. no." });
expect(tolkaKod("abc12a")).toEqual({ varde: "ABC12A", typ: "Reg. no." });
expect(tolkaKod("ABC 123")?.typ).toBe("Reg. no.");
});
it("plockar ut identifieraren ur QR-innehåll med fält eller URL", () => {
@@ -23,12 +23,12 @@ describe("avläst kod klassificeras innan den används", () => {
varde: "YV1DZ8256F2123456",
typ: "VIN",
});
expect(tolkaKod("REGNR: ABC123")).toEqual({ varde: "ABC123", typ: "Regnr" });
expect(tolkaKod("REGNR: ABC123")).toEqual({ varde: "ABC123", typ: "Reg. no." });
expect(tolkaKod("vin YV1DZ8256F2123456")?.typ).toBe("VIN");
});
it("tar maskin-/serienummer som de är men avvisar fritext och nakna URL:er", () => {
expect(tolkaKod("SN-4471/A")).toEqual({ varde: "SN4471/A", typ: "Serienummer" });
expect(tolkaKod("SN-4471/A")).toEqual({ varde: "SN4471/A", typ: "Serial number" });
expect(tolkaKod("https://exempel.se/nagot-helt-annat")).toBeNull();
expect(tolkaKod("Kontrollera oljenivån innan start")).toBeNull();
expect(tolkaKod(" ")).toBeNull();
+32 -32
View File
@@ -33,9 +33,9 @@ export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendatio
export const AI_RADTYP_LABEL: Record<AiRadTyp, string> = {
observation: "Observation",
verifierat: "Verifierat",
hypotes: "Hypotes (ej verifierad)",
rekommendation: "Rekommendation",
verifierat: "Verified",
hypotes: "Hypothesis (unverified)",
rekommendation: "Recommendation",
};
export interface AiRad {
@@ -58,11 +58,11 @@ export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning?
if (brief.objekt) rader.push(`Objekt: ${brief.objekt.beskrivning} (${brief.objekt.identifierare})`);
if (brief.felbeskrivning) rader.push(`Felbeskrivning: ${brief.felbeskrivning}`);
if (brief.utfordaKontroller.length > 0) {
rader.push("Utförda kontroller:");
rader.push("Checks performed:");
for (const k of brief.utfordaKontroller) rader.push(`- ${k.text}${k.resultat ? `: ${k.resultat}` : ""}`);
}
if (brief.observationer.length > 0) {
rader.push("Observationer och mätvärden:");
rader.push("Observations and measurements:");
for (const o of brief.observationer) rader.push(`- ${o}`);
}
if (brief.ejKontrollerat.length > 0) {
@@ -87,7 +87,7 @@ export function byggGranskningsPrompt(arende: Arende, metodik: Metodik): string
export function tolkaAiSvar(data: unknown): AiSvar {
const svar = data as AiSvar;
if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") {
throw new Error("Oväntat AI-svarsformat");
throw new Error("Unexpected AI response format");
}
const giltiga: AiRadTyp[] = ["observation", "verifierat", "hypotes", "rekommendation"];
const rader = svar.rader.filter(
@@ -101,7 +101,7 @@ export function tolkaAiSvar(data: unknown): AiSvar {
// (OCR + layoutförståelse) och returnerar strukturerade fält med
// konfidens per värde. Bara osäkra fält behöver granskas.
export type ArbetsorderGrupp = "Kund" | "Personbil" | "Verkstad" | "Ärende";
export type ArbetsorderGrupp = "Customer" | "Vehicle" | "Workshop" | "Case";
export interface TolkatFalt {
id: string;
@@ -114,36 +114,36 @@ export interface TolkatFalt {
}
export const ARBETSORDER_FALT: { id: string; grupp: ArbetsorderGrupp; etikett: string }[] = [
{ id: "kund_namn", grupp: "Kund", etikett: "Namn" },
{ id: "kund_foretag", grupp: "Kund", etikett: "Företag" },
{ id: "kund_telefon", grupp: "Kund", etikett: "Telefon" },
{ id: "kund_epost", grupp: "Kund", etikett: "E-post" },
{ id: "fordon_regnr", grupp: "Personbil", etikett: "Regnr" },
{ id: "fordon_vin", grupp: "Personbil", etikett: "VIN" },
{ id: "fordon_marke", grupp: "Personbil", etikett: "Märke" },
{ id: "fordon_modell", grupp: "Personbil", etikett: "Modell" },
{ id: "fordon_motor", grupp: "Personbil", etikett: "Motor" },
{ id: "fordon_arsmodell", grupp: "Personbil", etikett: "Årsmodell" },
{ id: "fordon_matarstallning", grupp: "Personbil", etikett: "Mätarställning" },
{ id: "fordon_motorkod", grupp: "Personbil", etikett: "Motorkod" },
{ id: "fordon_vaxellada", grupp: "Personbil", etikett: "Växellåda" },
{ id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" },
{ id: "ao_claim", grupp: "Verkstad", etikett: "Claim-/garantinr" },
{ id: "ao_skadenummer", grupp: "Verkstad", etikett: "Skadenummer" },
{ id: "ao_referens", grupp: "Verkstad", etikett: "Intern referens" },
{ id: "ao_serviceradgivare", grupp: "Verkstad", etikett: "Servicerådgivare" },
{ id: "ao_bokningsdatum", grupp: "Verkstad", etikett: "Bokningsdatum" },
{ id: "felbeskrivning", grupp: "Ärende", etikett: "Felbeskrivning" },
{ id: "kund_namn", grupp: "Customer", etikett: "Name" },
{ id: "kund_foretag", grupp: "Customer", etikett: "Company" },
{ id: "kund_telefon", grupp: "Customer", etikett: "Telephone" },
{ id: "kund_epost", grupp: "Customer", etikett: "E-mail" },
{ id: "fordon_regnr", grupp: "Vehicle", etikett: "Reg. no." },
{ id: "fordon_vin", grupp: "Vehicle", etikett: "VIN" },
{ id: "fordon_marke", grupp: "Vehicle", etikett: "Make" },
{ id: "fordon_modell", grupp: "Vehicle", etikett: "Model" },
{ id: "fordon_motor", grupp: "Vehicle", etikett: "Motor" },
{ id: "fordon_arsmodell", grupp: "Vehicle", etikett: "Model year" },
{ id: "fordon_matarstallning", grupp: "Vehicle", etikett: "Odometer" },
{ id: "fordon_motorkod", grupp: "Vehicle", etikett: "Engine code" },
{ id: "fordon_vaxellada", grupp: "Vehicle", etikett: "Transmission" },
{ id: "ao_nummer", grupp: "Workshop", etikett: "Work order no." },
{ id: "ao_claim", grupp: "Workshop", etikett: "Claim / warranty no." },
{ id: "ao_skadenummer", grupp: "Workshop", etikett: "Claim number" },
{ id: "ao_referens", grupp: "Workshop", etikett: "Internal reference" },
{ id: "ao_serviceradgivare", grupp: "Workshop", etikett: "Service advisor" },
{ id: "ao_bokningsdatum", grupp: "Workshop", etikett: "Booking date" },
{ id: "felbeskrivning", grupp: "Case", etikett: "Fault description" },
];
const ARBETSORDER_PROMPT =
"Tolka den bifogade arbetsordern från en fordonsverkstad och extrahera fälten.";
"Interpret the attached work order from a vehicle workshop and extract the fields.";
// Rensar AI-svaret: okända fält-id filtreras, konfidens klipps till 01,
// tomma värden tas bort och etikett/grupp slås upp ur katalogen.
export function normaliseraArbetsorder(data: unknown): TolkatFalt[] {
const rader = (data as { falt?: unknown }).falt;
if (!Array.isArray(rader)) throw new Error("Oväntat tolkningsformat");
if (!Array.isArray(rader)) throw new Error("Unexpected interpretation format");
const resultat: TolkatFalt[] = [];
for (const rad of rader as Partial<TolkatFalt>[]) {
const def = ARBETSORDER_FALT.find((f) => f.id === rad?.id);
@@ -188,7 +188,7 @@ export interface InstrumentTolkning {
export function normaliseraInstrument(data: unknown): InstrumentTolkning {
const d = data as Partial<InstrumentTolkning>;
if (!d || typeof d.instrumenttyp !== "string" || !Array.isArray(d.varden)) {
throw new Error("Oväntat avläsningsformat");
throw new Error("Unexpected reading format");
}
const varden: InstrumentVarde[] = [];
for (const rad of d.varden as Partial<InstrumentVarde>[]) {
@@ -208,7 +208,7 @@ export async function lasAvInstrument(
): Promise<{ tolkning: InstrumentTolkning; modell: string } | null> {
const resultat = await anropa(
"instrumentavlasning",
"Läs av instrumentet/skärmen i den bifogade bilden.",
"Read the instrument or display in the attached image.",
{ bild: dataUrl },
);
return resultat ? { tolkning: normaliseraInstrument(resultat.svar), modell: resultat.modell } : null;
@@ -265,7 +265,7 @@ async function anropa(
resultat = svar;
}
const { modell, svar } = resultat as { modell?: string; svar?: unknown };
if (typeof modell !== "string") throw new Error("Oväntat svar från AI-endpointen");
if (typeof modell !== "string") throw new Error("Unexpected response from the AI endpoint");
return { modell, svar };
}
+11 -11
View File
@@ -188,17 +188,17 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
// fält → starta diagnos) kan provas ändå. Märks alltid som demo i UI:t.
export function byggDemoTolkning(): TolkatFalt[] {
return [
{ id: "kund_namn", etikett: "Namn", grupp: "Kund", varde: "Anders Svensson", konfidens: 0.98 },
{ id: "kund_telefon", etikett: "Telefon", grupp: "Kund", varde: "070-123 45 67", konfidens: 0.91, omrade: { x: 0.08, y: 0.22, bredd: 0.3, hojd: 0.04 } },
{ id: "fordon_regnr", etikett: "Regnr", grupp: "Personbil", varde: "ABC123", konfidens: 0.99, omrade: { x: 0.62, y: 0.12, bredd: 0.2, hojd: 0.05 } },
{ id: "fordon_vin", etikett: "VIN", grupp: "Personbil", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.55, y: 0.19, bredd: 0.38, hojd: 0.04 } },
{ id: "fordon_marke", etikett: "Märke", grupp: "Personbil", varde: "Volvo", konfidens: 0.99 },
{ id: "fordon_modell", etikett: "Modell", grupp: "Personbil", varde: "XC60 D4", konfidens: 0.97 },
{ id: "fordon_arsmodell", etikett: "Årsmodell", grupp: "Personbil", varde: "2019", konfidens: 0.96 },
{ id: "fordon_matarstallning", etikett: "Mätarställning", grupp: "Personbil", varde: "8 432 mil", konfidens: 0.87, omrade: { x: 0.62, y: 0.27, bredd: 0.18, hojd: 0.04 } },
{ id: "ao_nummer", etikett: "Arbetsordernr", grupp: "Verkstad", varde: "AO-2496", konfidens: 0.99 },
{ id: "ao_serviceradgivare", etikett: "Servicerådgivare", grupp: "Verkstad", varde: "M. Lindqvist", konfidens: 0.82 },
{ id: "felbeskrivning", etikett: "Felbeskrivning", grupp: "Ärende", varde: "Kunden upplever vibrationer i ratten vid ca 90 km/h", konfidens: 0.97, omrade: { x: 0.08, y: 0.45, bredd: 0.84, hojd: 0.07 } },
{ id: "kund_namn", etikett: "Namn", grupp: "Customer", varde: "Anders Svensson", konfidens: 0.98 },
{ id: "kund_telefon", etikett: "Telefon", grupp: "Customer", varde: "070-123 45 67", konfidens: 0.91, omrade: { x: 0.08, y: 0.22, bredd: 0.3, hojd: 0.04 } },
{ id: "fordon_regnr", etikett: "Regnr", grupp: "Vehicle", varde: "ABC123", konfidens: 0.99, omrade: { x: 0.62, y: 0.12, bredd: 0.2, hojd: 0.05 } },
{ id: "fordon_vin", etikett: "VIN", grupp: "Vehicle", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.55, y: 0.19, bredd: 0.38, hojd: 0.04 } },
{ id: "fordon_marke", etikett: "Märke", grupp: "Vehicle", varde: "Volvo", konfidens: 0.99 },
{ id: "fordon_modell", etikett: "Modell", grupp: "Vehicle", varde: "XC60 D4", konfidens: 0.97 },
{ id: "fordon_arsmodell", etikett: "Årsmodell", grupp: "Vehicle", varde: "2019", konfidens: 0.96 },
{ id: "fordon_matarstallning", etikett: "Mätarställning", grupp: "Vehicle", varde: "8 432 mil", konfidens: 0.87, omrade: { x: 0.62, y: 0.27, bredd: 0.18, hojd: 0.04 } },
{ id: "ao_nummer", etikett: "Arbetsordernr", grupp: "Workshop", varde: "AO-2496", konfidens: 0.99 },
{ id: "ao_serviceradgivare", etikett: "Servicerådgivare", grupp: "Workshop", varde: "M. Lindqvist", konfidens: 0.82 },
{ id: "felbeskrivning", etikett: "Felbeskrivning", grupp: "Case", varde: "Kunden upplever vibrationer i ratten vid ca 90 km/h", konfidens: 0.97, omrade: { x: 0.08, y: 0.45, bredd: 0.84, hojd: 0.07 } },
];
}
+20 -20
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: "High",
medel: "Medium",
lag: "Low",
};
export type TidKategori =
@@ -20,20 +20,20 @@ export type TidKategori =
| "paus";
export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
aktiv_felsokning: "Aktiv felsökning",
vantetid: "Väntetid",
aktiv_felsokning: "Active diagnosis",
vantetid: "Waiting time",
administration: "Administration",
reservdelssokning: "Reservdelssökning",
provkorning: "Provkörning",
kundkontakt: "Kundkontakt",
paus: "Paus",
reservdelssokning: "Parts search",
provkorning: "Road test",
kundkontakt: "Customer contact",
paus: "Break",
};
// Kundens besked på ett lämnat åtgärdsförslag.
export const KUNDBESLUT_LABEL: Record<"godkant" | "avbojt" | "delvis", string> = {
godkant: "Godkänt",
avbojt: "Avböjt",
delvis: "Delvis godkänt",
godkant: "Approved",
avbojt: "Declined",
delvis: "Partly approved",
};
// Kvalitetskontrollens utfall efter utförd åtgärd.
@@ -41,10 +41,10 @@ export const KVALITETSKONTROLL_LABEL: Record<
"symptomet_borta" | "kvarstar" | "delvis" | "ej_verifierbar",
string
> = {
symptomet_borta: "Symptomet är borta",
kvarstar: "Symptomet kvarstår",
delvis: "Symptomet delvis kvar",
ej_verifierbar: "Kunde inte verifieras",
symptomet_borta: "Symptom is gone",
kvarstar: "Symptom remains",
delvis: "Symptom partly remains",
ej_verifierbar: "Could not be verified",
};
// Fordonsobjektet är den röda tråden genom hela ärendet: identiteten
@@ -52,9 +52,9 @@ export const KVALITETSKONTROLL_LABEL: Record<
// och export. Ärendereferenserna (AO/claim/skadenummer) hör till objektet
// så att de följer med i alla vyer utan att kunduppgifter läcker.
export interface Objekt {
typ: string; // t.ex. "Personbil", "Industrimaskin"
typ: string; // t.ex. "Passenger car", "Industrial machine"
identifierare: string; // reg.nr, VIN, serienummer …
identifieringsmetod: string; // "Regnr", "VIN", "Serienummer", "Manuell inmatning"
identifieringsmetod: string; // "Reg. no.", "VIN", "Serial number", "Manual entry"
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
kund?: string;
vin?: string;
@@ -331,7 +331,7 @@ export function handelseRubrik(post: LoggPost): string {
case "atgard_utford":
return h.utford
? `Åtgärd utförd: ${h.beskrivning}${h.delar ? ` (delar: ${h.delar})` : ""}`
: `Ingen åtgärd utförd — ${h.motivering ?? "orsak saknas"}`;
: `Ingen åtgärd utförd — ${h.motivering ?? "reason missing"}`;
case "kvalitetskontroll":
return `Kvalitetskontroll: ${KVALITETSKONTROLL_LABEL[h.resultat]}${h.beskrivning}`;
case "export_skapad":
@@ -341,6 +341,6 @@ export function handelseRubrik(post: LoggPost): string {
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
}
case "arende_avslutat":
return h.signatur ? `Felsökning avslutad — signerad av ${h.signatur}` : "Felsökning avslutad";
return h.signatur ? `Felsökning avslutad — signerad av ${h.signatur}` : "Diagnosis closed";
}
}
+4 -4
View File
@@ -26,10 +26,10 @@ export function tidDatum(iso: string): string {
export function skalaNerFoto(fil: File, maxSida = 1024): Promise<string> {
return new Promise((resolve, reject) => {
const lasare = new FileReader();
lasare.onerror = () => reject(new Error("Kunde inte läsa filen"));
lasare.onerror = () => reject(new Error("Could not read the file"));
lasare.onload = () => {
const bild = new Image();
bild.onerror = () => reject(new Error("Kunde inte tolka bilden"));
bild.onerror = () => reject(new Error("Could not interpret the image"));
bild.onload = () => {
const skala = Math.min(1, maxSida / Math.max(bild.width, bild.height));
const canvas = document.createElement("canvas");
@@ -51,12 +51,12 @@ export const MAX_VIDEO_BYTE = 2_500_000;
export function lasVideo(fil: File): Promise<string> {
return new Promise((los, avvisa) => {
if (fil.size > MAX_VIDEO_BYTE) {
avvisa(new Error("Videon är för stor — spela in ett kort klipp (ca 1015 sekunder)."));
avvisa(new Error("The video is too large — record a short clip (about 1015 seconds)."));
return;
}
const lasare = new FileReader();
lasare.onload = () => los(lasare.result as string);
lasare.onerror = () => avvisa(new Error("Videon kunde inte läsas."));
lasare.onerror = () => avvisa(new Error("The video could not be read."));
lasare.readAsDataURL(fil);
});
}
+11 -11
View File
@@ -7,21 +7,21 @@
import { plattformAktiv, plattformFetch, plattformToken } from "./plattform";
export const ALLA_OBJEKTTYPER = [
"Personbil",
"Lastbil/Buss",
"Entreprenadmaskin",
"Industrimaskin",
"Elsystem",
"Hydraulik",
"Övrigt",
"Passenger car",
"Truck / bus",
"Construction machine",
"Industrial machine",
"Electrical system",
"Hydraulics",
"Other",
];
export const ALLA_IDENTIFIERINGSMETODER = [
"Regnr",
"Reg. no.",
"VIN",
"Serienummer",
"Maskinnummer",
"Manuell inmatning",
"Serial number",
"Machine number",
"Manual entry",
];
export interface Installningar {
+5 -5
View File
@@ -168,12 +168,12 @@ export interface NastaSteg {
*/
export const SPARRFRAGOR: Record<string, { orsak: string; atgard: string }> = {
"hogvolt/sakerhet/behorighet": {
orsak: "Arbete på högvoltsystem kräver dokumenterad behörighet.",
atgard: "Lämna över ärendet till en behörig tekniker. Arbetet får inte påbörjas utan behörighet.",
orsak: "Work on high-voltage systems requires documented authorisation.",
atgard: "Hand the case to an authorised technician. Work must not begin without authorisation.",
},
"hogvolt/sakerhet/avstangt": {
orsak: "Fordonet är inte spänningslöst enligt tillverkarens rutin.",
atgard: "Gör fordonet spänningslöst enligt tillverkarens anvisning innan något ingrepp påbörjas.",
orsak: "The vehicle is not de-energised per the manufacturer's procedure.",
atgard: "De-energise the vehicle per the manufacturer's instruction before any work begins.",
},
};
@@ -197,7 +197,7 @@ export function nastaSteg(arende: Arende, metodik: Metodik): NastaSteg {
// Säkerhetsspärr: ett nekande svar stoppar metodiken här i stället
// för att räknas som besvarat och släppa fram nästa steg.
const sparr = SPARRFRAGOR[`${metodik.id}/${steg.id}/${fraga.id}`];
if (sparr && svar.get(`${steg.id}/${fraga.id}`) !== "Ja") {
if (sparr && svar.get(`${steg.id}/${fraga.id}`) !== "Yes") {
return { steg, fraga, klart: false, sparr };
}
}
+14 -14
View File
@@ -262,19 +262,19 @@ export interface TillforlitlighetsRad {
export function tillforlitlighet(arende: Arende): TillforlitlighetsRad[] {
const rader: TillforlitlighetsRad[] = [];
if (objekt(arende)) rader.push({ niva: "hog", text: "Objekt identifierat" });
if (foton(arende).length > 0) rader.push({ niva: "hog", text: "Bilder dokumenterade" });
if (objekt(arende)) rader.push({ niva: "hog", text: "Object identified" });
if (foton(arende).length > 0) rader.push({ niva: "hog", text: "Images documented" });
if (arende.handelser.some((p) => p.handelse.typ === "matvarde")) {
rader.push({ niva: "hog", text: "Mätvärden registrerade" });
rader.push({ niva: "hog", text: "Measurements recorded" });
}
if (utfordaKontroller(arende).length > 0) {
rader.push({ niva: "hog", text: "Kontroller dokumenterade" });
rader.push({ niva: "hog", text: "Checks documented" });
}
// Felorsaken är per definition inte verifierad förrän ärendet avslutats
// med en verifierad slutsats — MVP:t markerar den alltid som ej verifierad
// så länge ärendet är öppet.
if (!arAvslutat(arende)) {
rader.push({ niva: "medel", text: "Felorsak ännu inte verifierad" });
rader.push({ niva: "medel", text: "Root cause not yet verified" });
}
return rader;
}
@@ -306,7 +306,7 @@ export function brief(arende: Arende, metodik: Metodik, nu?: string): Brief {
if (!rekommenderat.includes(text)) rekommenderat.push(text);
}
if (rekommenderat.length === 0) {
rekommenderat.push("Samtliga kontroller i metodiken utförda. Verifiera slutsats eller utöka felsökningen.");
rekommenderat.push("Every check in the methodology is complete. Verify the conclusion or extend the diagnosis.");
}
}
return {
@@ -332,25 +332,25 @@ export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string):
if (b.objekt) rader.push(`Objekt: ${b.objekt.beskrivning} (${b.objekt.identifierare})${b.objekt.kund ? ` Kund: ${b.objekt.kund}` : ""}`);
if (b.felbeskrivning) rader.push(`Felbeskrivning: ${b.felbeskrivning}`);
rader.push("");
rader.push("Utförda kontroller:");
if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)");
rader.push("Checks performed:");
if (b.utfordaKontroller.length === 0) rader.push(" (none yet)");
for (const k of b.utfordaKontroller)
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)");
rader.push("Observations:");
if (b.observationer.length === 0) rader.push(" (none yet)");
for (const o of b.observationer) rader.push(`${o}`);
if (b.hypoteser.length > 0) {
rader.push("");
rader.push("Hypoteser (ej verifierade):");
rader.push("Hypotheses (unverified):");
for (const h of b.hypoteser) rader.push(` ? ${h.text}`);
}
rader.push("");
rader.push("Ej kontrollerat:");
if (b.ejKontrollerat.length === 0) rader.push(" (allt i metodiken utfört)");
rader.push("Not checked:");
if (b.ejKontrollerat.length === 0) rader.push(" (everything in the methodology is complete)");
for (const e of b.ejKontrollerat) rader.push(` ${e}`);
rader.push("");
rader.push("Rekommenderat nästa steg:");
rader.push("Recommended next step:");
b.rekommenderatNastaSteg.forEach((s, i) => rader.push(` ${i + 1}. ${s}`));
rader.push("");
rader.push(`Total arbetstid: ${b.totalArbetstid}`);
+6 -6
View File
@@ -11,7 +11,7 @@
// Den avlästa koden klassificeras alltid innan den används, så att rätt
// identifieringsmetod sätts på fordonsobjektet.
export type KodTyp = "VIN" | "Regnr" | "Serienummer";
export type KodTyp = "VIN" | "Reg. no." | "Serial number";
export interface AvlastKod {
varde: string;
@@ -37,16 +37,16 @@ export function tolkaKod(ratext: string): AvlastKod | null {
const vinFalt = text.match(/(?:vin|chassi)[=:/\s]+([A-HJ-NPR-Z0-9]{17})/i);
if (vinFalt) return { varde: vinFalt[1].toUpperCase(), typ: "VIN" };
const regnrFalt = text.match(/(?:reg(?:nr)?|plate)[=:/\s]+([A-Za-z]{3}\s?[0-9]{2}[0-9A-Za-z])/i);
if (regnrFalt) return { varde: regnrFalt[1].replace(/\s/g, "").toUpperCase(), typ: "Regnr" };
if (regnrFalt) return { varde: regnrFalt[1].replace(/\s/g, "").toUpperCase(), typ: "Reg. no." };
const rent = text.replace(/[\s-]/g, "").toUpperCase();
if (VIN_MONSTER.test(rent)) return { varde: rent, typ: "VIN" };
if (REGNR_MONSTER.test(rent)) return { varde: rent, typ: "Regnr" };
if (REGNR_MONSTER.test(rent)) return { varde: rent, typ: "Reg. no." };
// Allt annat (maskin-/serienummer) tas som det är, men bara om det ser
// ut som en identifierare — inte som fritext eller en hel URL.
if (/^[A-Z0-9._/-]{4,40}$/.test(rent) && !/^HTTPS?:/.test(rent)) {
return { varde: rent, typ: "Serienummer" };
return { varde: rent, typ: "Serial number" };
}
return null;
}
@@ -78,7 +78,7 @@ export async function startaSkanning(
): Promise<Skanning> {
const Detektor = detektorKlass();
if (!Detektor) {
vidFel?.("Webbläsaren saknar streckkodsläsningfotografera typskylten i stället.");
vidFel?.("The browser has no barcode readerphotograph the type plate instead.");
return { stoppa: () => {} };
}
@@ -104,7 +104,7 @@ export async function startaSkanning(
// starta. Detektorn får helt enkelt tomma rutor tills strömmen är i gång.
void video.play().catch(() => {});
} catch {
vidFel?.("Kameran kunde inte startaskontrollera behörigheten eller fotografera typskylten.");
vidFel?.("The camera could not be startedcheck the permission or photograph the type plate.");
return { stoppa };
}
+215 -215
View File
@@ -80,17 +80,17 @@ import { bilageFalt } from "@/felsokning/bilagor";
const FLIKAR = [
{ id: "guide", label: "Guide" },
{ id: "logg", label: "Logg" },
{ id: "logg", label: "Log" },
{ id: "brief", label: "Brief" },
{ id: "rapport", label: "Rapport" },
{ id: "rapport", label: "Report" },
] as const;
type Flik = (typeof FLIKAR)[number]["id"];
const SYNKSTATUS_LABEL: Record<SynkStatus, string> = {
lokal: "Lokalt läge",
synkar: "Synkar …",
synkad: "Synkad",
lokal: "Local mode",
synkar: "Synchronizing …",
synkad: "Synchronized",
offline: "Offline",
};
@@ -144,8 +144,8 @@ export default function ArendeSida() {
if (!arende || !id) {
return (
<FelsokningSkal rubrik="Ärendet hittades inte" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<p className="text-[14px] text-[#333333]">Ärendet finns inte den här enheten.</p>
<FelsokningSkal rubrik="Case not found" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<p className="text-[14px] text-[#333333]">The case does not exist on this device.</p>
</FelsokningSkal>
);
}
@@ -172,12 +172,12 @@ export default function ArendeSida() {
<FelsokningSkal
bred
rubrik={`#${arende.nummer} ${o?.beskrivning ?? ""}`}
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
tillbaka={{ till: "/felsokning", text: "Cases" }}
hoger={
<div className="text-right">
<p className="text-[13px] font-semibold text-white">{total}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#A9C3DE]">
{avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]}
{avslutat ? "Closed" : "In progress"} · {SYNKSTATUS_LABEL[synkStatus]}
</p>
</div>
}
@@ -254,16 +254,16 @@ function IdentitetsRad({ arende, skicka }: { arende: Arende; skicka: (h: Handels
) : null;
return (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 text-[12px] print:hidden">
{del("AO", idn.arbetsorder)}
{del("WO", idn.arbetsorder)}
{del("Claim", idn.claim)}
{del("Skadenr", idn.skadenummer)}
{del("Claim no.", idn.skadenummer)}
{del("", idn.beskrivning)}
{del("Regnr", idn.identifierare)}
{del("Reg. no.", idn.identifierare)}
{del("VIN", idn.vin)}
{del("Miltal", idn.miltal)}
{del("Ansvarig", idn.ansvarig)}
{del("Mileage", idn.miltal)}
{del("Responsible", idn.ansvarig)}
<label className="ml-auto flex items-center gap-1">
<span className="text-[#707070]">Ärendetyp</span>
<span className="text-[#707070]">Case type</span>
<select
value={typ}
disabled={idn.avslutat}
@@ -326,7 +326,7 @@ function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; s
<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>}
{laser && <p className="animate-pulse py-1 text-center text-[12px] font-semibold text-[#00437A]">Reading the odometer </p>}
{foto && !laser && (
<div className="mt-2">
{demo && (
@@ -335,7 +335,7 @@ function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; s
</p>
)}
<img src={foto} alt="Instrument panel" className="mb-2 max-h-40 border border-[#C6C6C6]" />
<TextFalt label={`Mätarställning (${lage === "ingaende" ? "ingående" : "utgående"})`} varde={varde} satt={setVarde} platshallare="T.ex. 84 320 km" />
<TextFalt label={`Mätarställning (${lage === "ingaende" ? "incoming" : "outgoing"})`} varde={varde} satt={setVarde} platshallare="e.g. 84,320 km" />
<StorKnapp
disabled={!varde.trim()}
onClick={() => {
@@ -399,7 +399,7 @@ function TidigareArenden({ arende, skicka }: { arende: Arende; skicka: (h: Hande
<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.avslutat ? "closed" : "in progress"}
{rad.felbeskrivning && <span className="text-[#4A5560]"> {rad.felbeskrivning}</span>}
</p>
{rad.felorsaker.map((f, i) => (
@@ -437,7 +437,7 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha
const harTidiga = arende.handelser.some((p) => p.handelse.typ === "observation" || p.handelse.typ === "foto");
return (
<Panel rubrik="Pre-diagnostikinnan felsökningen börjar">
<Panel rubrik="Pre-diagnosticsbefore the work begins">
{rader.map((r) => (
<p key={r.id} className="py-0.5 text-[13px]">
<span className={r.klar ? "text-[#1E6B34]" : "text-[#8B1A1A]"}>{r.klar ? <IkonCheck /> : "☐"}</span> {r.rubrik}
@@ -456,16 +456,16 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha
Ja kontrollerad
</StorKnapp>
<StorKnapp variant={historikVal === "nej" ? "fara" : "sekundar"} onClick={() => setHistorikVal("nej")}>
Nej
No
</StorKnapp>
</div>
{historikVal && (
<>
<TextFalt
label={historikVal === "ja" ? "Relevanta tidigare arbeten (valfritt — orsakskedja)" : "Orsak till att historiken inte kontrollerats (obligatoriskt)"}
label={historikVal === "ja" ? "Relevant previous work (optional — causal chain)" : "Reason the history was not checked (required)"}
varde={historikText}
satt={setHistorikText}
platshallare={historikVal === "ja" ? "T.ex. Vattenpump bytt för 8 mån sedan — misstänkt läckage samma område" : ""}
platshallare={historikVal === "ja" ? "e.g. Water pump replaced 8 months ago — suspected leak in the same area" : ""}
rost
/>
<StorKnapp
@@ -476,7 +476,7 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha
setHistorikText("");
}}
>
Dokumentera
Document
</StorKnapp>
</>
)}
@@ -541,26 +541,26 @@ function ReproduceringPanel({ skicka }: { skicka: (h: Handelse) => void }) {
const [text, setText] = useState("");
const etikett =
status === "ja"
? "Hur reproducerades felet? (förhållanden, hastighet, temperatur, belastning …)"
? "How was the fault reproduced? (conditions, speed, temperature, load …)"
: status === "delvis"
? "Vad kunde respektive kunde inte reproduceras?"
: "Motivering (obligatorisk) — varför kunde felet inte reproduceras?";
? "What could and could not be reproduced?"
: "Rationale (required) — why could the fault not be reproduced?";
return (
<Panel rubrik="Symptomverifiering — reproducering">
<Panel rubrik="Symptom verification — reproduction">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har kundens fel kunnat reproduceras?
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{(["ja", "delvis", "nej"] as const).map((val) => (
<StorKnapp key={val} variant={status === val ? "primar" : "sekundar"} onClick={() => setStatus(val)}>
{val === "ja" ? "Ja" : val === "delvis" ? "Delvis" : "Nej"}
{val === "ja" ? "Yes" : val === "delvis" ? "Partly" : "No"}
</StorKnapp>
))}
</div>
{status && (
<>
<TextFalt label={etikett} varde={text} satt={setText} flerRad rost
platshallare={status === "ja" ? "T.ex. Reproducerad tre gånger vid 92108 km/h på plan väg, varm motor" : ""} />
platshallare={status === "ja" ? "e.g. Reproduced three times at 92108 km/h on level road, engine warm" : ""} />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
@@ -599,15 +599,15 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
const spara = () => {
const avvikelseFel = granskaAvvikelse(avvikelse);
if (avvikelseFel) return setFel(avvikelseFel);
if (orsaker.length === 0) return setFel("Välj minst en orsakskategori.");
if (underlag.length === 0) return setFel("Koppla minst en evidenskälla till bedömningen.");
if (orsaker.length === 0) return setFel("Select at least one cause category.");
if (underlag.length === 0) return setFel("Link at least one evidence source to the assessment.");
const saknat = underlag.find((k) => !underlagFinns(arende, k));
if (saknat) return setFel(`Underlaget ”${saknat}” finns inte i ärendets logg — dokumentera det först eller välj en annan källa.`);
if (orsaker.includes("Okänd orsak") && !motivering.trim())
return setFel("Okänd orsak kräver en motivering till varför orsaken inte kunnat fastställas.");
if (orsaker.includes("Unknown cause") && !motivering.trim())
return setFel("An unknown cause requires a reason why the cause could not be established.");
if (sakerhet !== "hog" && !ytterligare.trim())
return setFel("Vid medel/låg säkerhet: ange vilka ytterligare kontroller som skulle stärka bedömningen.");
if (!atgard.trim()) return setFel("Ange rekommenderad åtgärd.");
return setFel("At medium or low confidence: state which further checks would strengthen the assessment.");
if (!atgard.trim()) return setFel("State the recommended action.");
skicka({
typ: "felorsak",
avvikelse: avvikelse.trim(),
@@ -630,7 +630,7 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
};
return (
<Panel rubrik="Felorsaksanalys — obligatorisk före avslut">
<Panel rubrik="Root cause analysisrequired before closing">
{dokumenterade.map((p) => {
const h = p.handelse;
if (h.typ !== "felorsak") return null;
@@ -651,14 +651,14 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
) : (
<div>
<TextFalt
label="1. Konstaterad avvikelse (inte bara komponenten)"
label="1. Established deviation (not just the component)"
varde={avvikelse}
satt={setAvvikelse}
flerRad
rost
platshallare="T.ex. Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse."
platshallare="e.g. The starter motor does not engage despite correct supply voltage and a good earth connection."
/>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">2. Mest sannolik orsak (en eller flera)</p>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">2. Most probable cause (one or more)</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-3">
{aktivtRegelpaket().orsakskategorier.map((o) => (
<button
@@ -672,7 +672,7 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
</button>
))}
</div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">3. Underlag som stöder bedömningen</p>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">3. Evidence supporting the assessment</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-4">
{aktivtRegelpaket().underlagskallor.map((k) => (
<button
@@ -686,7 +686,7 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
</button>
))}
</div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">4. Säkerhet i bedömningen</p>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">4. Confidence in the assessment</p>
<div className="mb-3 grid grid-cols-3 gap-2">
{(["hog", "medel", "lag"] as const).map((n) => (
<button
@@ -702,28 +702,28 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
</div>
{sakerhet !== "hog" && (
<TextFalt
label="Vilka ytterligare kontroller skulle stärka bedömningen?"
label="Which further checks would strengthen the assessment?"
varde={ytterligare}
satt={setYtterligare}
rost
/>
)}
{orsaker.includes("Okänd orsak") && (
{orsaker.includes("Unknown cause") && (
<TextFalt
label="Motivering — varför kunde orsaken inte fastställas?"
label="Rationale — why could the cause not be established?"
varde={motivering}
satt={setMotivering}
flerRad
rost
/>
)}
<TextFalt label="Rekommenderad åtgärd" varde={atgard} satt={setAtgard} rost platshallare="T.ex. Balansera framhjulen och genomför ny provkörning." />
<TextFalt label="Recommended action" varde={atgard} satt={setAtgard} rost platshallare="e.g. Balance the front wheels and perform a new road test." />
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Avbryt
</StorKnapp>
<StorKnapp onClick={spara}>Spara felorsak</StorKnapp>
<StorKnapp onClick={spara}>Save root cause</StorKnapp>
</div>
</div>
)}
@@ -762,7 +762,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
const [kkText, setKkText] = useState("");
return (
<Panel rubrik="Åtgärd och kvalitetskontroll">
<Panel rubrik="Action and quality check">
{poster.map((p) => {
const h = p.handelse;
if (h.typ !== "atgard_utford") return null;
@@ -770,7 +770,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
<p key={p.id} className="border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
{h.utford ? (
<>
<span className="font-semibold">Utförd åtgärd:</span> {h.beskrivning}
<span className="font-semibold">Action performed:</span> {h.beskrivning}
{h.delar && <span className="text-[#4A5560]"> · Delar: {h.delar}</span>}
</>
) : (
@@ -792,7 +792,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
if (h.typ !== "atgardsforslag") return null;
return (
<p key={p.id} className="border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span className="font-semibold">Åtgärdsförslag till kund:</span> {h.beskrivning}
<span className="font-semibold">Proposed action for the customer:</span> {h.beskrivning}
{h.uppskattadKostnad && <span className="text-[#4A5560]"> · Uppskattad kostnad: {h.uppskattadKostnad}</span>}
</p>
);
@@ -827,14 +827,14 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
{forslagsLage && (
<div className="mb-3 border border-[#C6C6C6] bg-white p-2">
<TextFalt
label="Föreslagen åtgärd (visas för kunden i delningsvyn)"
label="Proposed action (shown to the customer in the shared view)"
varde={forslagText}
satt={setForslagText}
flerRad
rost
platshallare="T.ex. Balansera om höger framhjul och genomför ny provkörning."
platshallare="e.g. Rebalance the right front wheel and perform a new road test."
/>
<TextFalt label="Uppskattad kostnad (valfritt)" varde={kostnad} satt={setKostnad} platshallare="T.ex. 1 200 kr inkl. moms" />
<TextFalt label="Estimated cost (optional)" varde={kostnad} satt={setKostnad} platshallare="e.g. 1,200 incl. VAT" />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setForslagsLage(false)}>
Avbryt
@@ -878,7 +878,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
</div>
{beslutsVal && (
<>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">Hur lämnades beskedet?</p>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">How was the decision given?</p>
<div className="mb-2 grid grid-cols-3 gap-1">
{KUNDKANALER.map((k) => (
<button
@@ -893,7 +893,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
))}
</div>
<TextFalt
label={beslutsVal === "godkant" ? "Kommentar (valfritt)" : "Kundens motivering (obligatorisk)"}
label={beslutsVal === "godkant" ? "Comment (optional)" : "Customer's reason (required)"}
varde={beslutsKommentar}
satt={setBeslutsKommentar}
rost
@@ -929,7 +929,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
Dokumentera utförd åtgärd
</StorKnapp>
<StorKnapp variant="sekundar" onClick={() => setLage("ingen")}>
Ingen åtgärd utförd
No action performed
</StorKnapp>
</div>
)}
@@ -937,14 +937,14 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
{lage === "utford" && (
<div className="mt-2">
<TextFalt
label="Vad gjordes? (kopplat till den dokumenterade felorsaken)"
label="What was done? (tied to the documented root cause)"
varde={beskrivning}
satt={setBeskrivning}
flerRad
rost
platshallare="T.ex. Balanserade om höger framhjul, ny balansvikt 40 g på insidan."
platshallare="e.g. Rebalanced the right front wheel, new 40 g weight on the inner rim."
/>
<TextFalt label="Delar (valfritt)" varde={delar} satt={setDelar} platshallare="T.ex. Balansvikter, artikelnr 30-1234" rost />
<TextFalt label="Parts (optional)" varde={delar} satt={setDelar} platshallare="e.g. Balance weights, part no. 30-1234" rost />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("")}>
Avbryt
@@ -982,7 +982,7 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
</button>
))}
</div>
<TextFalt label="Eller egen orsak" varde={orsak && !INGEN_ATGARD_ORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
<TextFalt label="Or a cause of your own" varde={orsak && !INGEN_ATGARD_ORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
{beslut?.beslut === "avbojt" && (
<p className="mb-2 text-[11px] text-[#707070]">
Kunden har avböjt åtgärden via {beslut.kanal} beskedet finns registrerat i loggen.
@@ -995,12 +995,12 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
<StorKnapp
disabled={!orsak.trim()}
onClick={() => {
skicka({ typ: "atgard_utford", beskrivning: "Ingen åtgärd utförd", utford: false, motivering: orsak.trim() });
skicka({ typ: "atgard_utford", beskrivning: "No action performed", utford: false, motivering: orsak.trim() });
setLage("");
setOrsak("");
}}
>
Dokumentera
Document
</StorKnapp>
</div>
</div>
@@ -1034,16 +1034,16 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
<TextFalt
label={
kkResultat === "symptomet_borta"
? "Hur verifierades det? (förhållanden, hastighet, provkörning …)"
? "How was it verified? (conditions, speed, road test …)"
: kkResultat === "ej_verifierbar"
? "Varför kunde det inte verifieras? (obligatorisk)"
: "Vad kvarstår? (obligatorisk)"
? "Why could it not be verified? (required)"
: "What remains? (required)"
}
varde={kkText}
satt={setKkText}
flerRad
rost
platshallare="T.ex. Ny provkörning 80110 km/h på samma vägsträcka — ingen vibration."
platshallare="e.g. New road test at 80110 km/h on the same stretch — no vibration."
/>
<StorKnapp
disabled={!kkText.trim()}
@@ -1141,16 +1141,16 @@ function KontextPanel({
) : null;
return (
<div>
<Panel rubrik="Teknisk information">
{rad("Objekt", b.objekt?.beskrivning)}
<Panel rubrik="Technical information">
{rad("Object", b.objekt?.beskrivning)}
{rad("Ident", b.objekt?.identifierare)}
{rad("Kund", b.objekt?.kund)}
{rad("Ansvarig", b.ansvarig)}
{rad("Status", b.avslutat ? "Avslutat" : "Pågår")}
{rad("Synk", SYNKSTATUS_LABEL[synkStatus])}
{rad("Arbetstid", b.totalArbetstid)}
{rad("Customer", b.objekt?.kund)}
{rad("Responsible", b.ansvarig)}
{rad("Status", b.avslutat ? "Closed" : "In progress")}
{rad("Sync", SYNKSTATUS_LABEL[synkStatus])}
{rad("Labour time", b.totalArbetstid)}
</Panel>
<Panel rubrik="Tillförlitlighet">
<Panel rubrik="Reliability">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-[12px]">
<NivaBadge niva={r.niva} /> {r.text}
@@ -1158,7 +1158,7 @@ function KontextPanel({
))}
</Panel>
{!b.avslutat && b.rekommenderatNastaSteg.length > 0 && (
<Panel rubrik="Rekommenderat nästa steg">
<Panel rubrik="Recommended next step">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-[12px] text-[#333333]">
{i + 1}. {s}
@@ -1167,14 +1167,14 @@ function KontextPanel({
</Panel>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && (
<Panel rubrik="Teknisk rekommendation">
<Panel rubrik="Technical recommendation">
{senasteAi.handelse.rader.map((r, i) => (
<p key={i} className="py-0.5 text-[12px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[r.typ]}:</span> {r.text}
</p>
))}
<p className="mt-1 text-[12px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
<span className="font-semibold text-[#00437A]">Next step:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-[#707070]">
Beslutsstöd · presenteras aldrig som konstaterat fel
@@ -1205,8 +1205,8 @@ function InaktivitetsBanner({
<p className="mb-2 text-[14px] font-semibold text-[#00437A]">
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
</p>
<p className="mb-3 text-[#333333]">Beskriv kort vad som gjorts under denna period.</p>
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." rost />
<p className="mb-3 text-[#333333]">Briefly describe what was done during this period.</p>
<TextFalt label="What has been done?" varde={text} satt={setText} platshallare="e.g. Removed the instrument panel to reach the wiring harness." rost />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
@@ -1284,7 +1284,7 @@ function GuideFlik({
if (avslutat) {
return (
<Panel rubrik="Felsökningen är avslutad">
<Panel rubrik="The diagnosis is closed">
<p className="text-[14px] text-[#333333]">
Ärendet är låst för nya guidesteg. Loggen, briefen och rapporten finns kvar för spårbarhet och export.
</p>
@@ -1338,7 +1338,7 @@ function GuideFlik({
<Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}>
{steg.klart ? (
<>
<p className="mb-3 text-[15px] font-semibold">Samtliga steg i metodiken är dokumenterade.</p>
<p className="mb-3 text-[15px] font-semibold">Every step in the methodology is documented.</p>
<p className="mb-4 text-[#333333]">
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
med rekommenderade nästa steg.
@@ -1353,7 +1353,7 @@ function GuideFlik({
// hinder, inte en varning. Enda vägen framåt är att ändra
// förutsättningen, inte att klicka förbi.
<div className="border-2 border-[#B42318] bg-[#FEF3F2] p-4">
<p className="mb-2 text-[15px] font-semibold text-[#B42318]">Arbetet får inte fortsätta</p>
<p className="mb-2 text-[15px] font-semibold text-[#B42318]">Work must not continue</p>
<p className="mb-2 text-[#333333]">{steg.sparr.orsak}</p>
<p className="mb-3 font-semibold text-[#333333]">{steg.sparr.atgard}</p>
<p className="text-[12px] text-[#666666]">
@@ -1373,12 +1373,12 @@ function GuideFlik({
rubrik={
senasteAiSvar?.handelse.typ === "ai_svar"
? `Handledning (${senasteAiSvar.handelse.modell})`
: "Handledning"
: "Guidance"
}
>
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Systemet analyserar </p>}
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Analyzing </p>}
{aiStatus === "fel" && (
<p className="mb-2 font-semibold text-[#8B1A1A]">Analysen kunde inte hämtas försök igen. Metodiken fortsätter som vanligt.</p>
<p className="mb-2 font-semibold text-[#8B1A1A]">The analysis could not be retrieved try again. The methodology continues unaffected.</p>
)}
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
<>
@@ -1388,7 +1388,7 @@ function GuideFlik({
</p>
))}
<p className="mt-2 text-[14px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span>{" "}
<span className="font-semibold text-[#00437A]">Next step:</span>{" "}
{senasteAiSvar.handelse.nastaSteg}
</p>
</>
@@ -1414,7 +1414,7 @@ function GuideFlik({
{/* Utgående mätarställning: obligatorisk för kvalitetsgrinden när
ärendet avslutas — erbjuds så fort metodiken är genomarbetad. */}
{!arende.handelser.some((p) => p.handelse.typ === "matarstallning" && p.handelse.lage === "utgaende") && (
<Panel rubrik="Utgående mätarställning — inför avslut">
<Panel rubrik="Outgoing odometer reading — before closing">
<MatarstallningSteg lage="utgaende" skicka={skicka} />
</Panel>
)}
@@ -1498,8 +1498,8 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
<p className="mb-4 text-[17px] font-semibold leading-snug">{fraga.text}</p>
{fraga.svarstyp === "janej" && (
<div className="grid grid-cols-2 gap-2">
<StorKnapp onClick={() => svara("Ja")}>Ja</StorKnapp>
<StorKnapp onClick={() => svara("Nej")}>Nej</StorKnapp>
<StorKnapp onClick={() => svara("Yes")}>Yes</StorKnapp>
<StorKnapp onClick={() => svara("No")}>No</StorKnapp>
</div>
)}
{fraga.svarstyp === "val" && (
@@ -1513,7 +1513,7 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
)}
{fraga.svarstyp === "text" && (
<>
<TextFalt label="Svar" varde={text} satt={setText} rost />
<TextFalt label="Answer" varde={text} satt={setText} rost />
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
Spara svar
</StorKnapp>
@@ -1576,29 +1576,29 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
utford();
}}
/>
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
<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>
<TextFalt label="Observation (optional)" varde={resultat} satt={setResultat} rost />
<StorKnapp onClick={() => filRef.current?.click()}><IkonKamera /> Take a photograph verifies the check</StorKnapp>
<p className="mt-2 text-[12px] text-[#707070]">This check is verified with a photograph.</p>
</>
) : (
<>
<TextFalt
label={
krav === "matvarde"
? "Uppmätt värde (krävs för verifiering)"
? "Measured value (required for verification)"
: krav === "kommentar"
? "Vad observerades? (krävs för verifiering)"
: "Resultat / avläst värde (valfritt)"
? "What was observed? (required for verification)"
: "Result / value read (optional)"
}
varde={resultat}
satt={setResultat}
platshallare={krav === "matvarde" ? "T.ex. 2,4 bar samtliga hjul" : "T.ex. Säkringen är hel, spänning på båda sidor"}
platshallare={krav === "matvarde" ? "e.g. 2.4 bar on all wheels" : "e.g. The fuse is intact, voltage on both sides"}
rost
/>
{!kravUppfyllt && (
<p className="mb-3 text-[12px] font-semibold text-[#00437A]">
Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats lägg till en kort{" "}
{krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.
No {krav === "matvarde" ? "measurement" : "observation"} has been recorded add a short{" "}
{krav === "matvarde" ? "measurement entry" : "comment"} before the check can be verified.
</p>
)}
<StorKnapp disabled={!kravUppfyllt} onClick={utford}>
@@ -1656,7 +1656,7 @@ function Undantag({ vidUndantag }: { vidUndantag: (orsak: string) => void }) {
</button>
))}
</div>
<TextFalt label="Eller egen orsak" varde={orsak && !UNDANTAGSORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
<TextFalt label="Or a cause of your own" varde={orsak && !UNDANTAGSORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Avbryt
@@ -1671,12 +1671,12 @@ function Undantag({ vidUndantag }: { vidUndantag: (orsak: string) => void }) {
const DOKTYPER = [
{ id: "observation", label: "Observation" },
{ id: "matvarde", label: "Mätvärde" },
{ id: "foto", label: "Foto" },
{ id: "matvarde", label: "Measurement" },
{ id: "foto", label: "Photo" },
{ id: "video", label: "Video" },
{ id: "instrument", label: "Instrument" },
{ id: "hypotes", label: "Hypotes" },
{ id: "kommentar", label: "Kommentar" },
{ id: "hypotes", label: "Hypothesis" },
{ id: "comment", label: "Comment" },
] as const;
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
@@ -1721,10 +1721,10 @@ function SnabbDokumentation({
} else if (!resultat) {
setAvlasning({ foto, tolkning: byggDemoInstrument(), demo: true });
} else {
skicka({ typ: "foto", beskrivning: "Instrumentfoto — inga värden kunde läsas", dataUrl: foto });
skicka({ typ: "foto", beskrivning: "Instrument photograph — no values could be read", dataUrl: foto });
}
} catch {
skicka({ typ: "foto", beskrivning: "Instrumentfoto — avläsningen kunde inte nås", dataUrl: foto });
skicka({ typ: "foto", beskrivning: "Instrument photograph — the reader could not be reached", dataUrl: foto });
}
setLaserAv(false);
};
@@ -1746,7 +1746,7 @@ function SnabbDokumentation({
beskrivning: v.beskrivning,
varde: v.varde,
enhet: v.enhet,
kalla: "Instrumentavläsning (modelltolkad, bekräftad av tekniker)",
kalla: "Instrument reading (model-interpreted, confirmed by the technician)",
});
}
paSparad?.(
@@ -1763,7 +1763,7 @@ function SnabbDokumentation({
skicka({ typ: "observation", text: t });
paSparad?.(`Observation: ${t}`);
}
if (typ === "kommentar" && t) {
if (typ === "comment" && t) {
skicka({ typ: "kommentar", text: t });
paSparad?.(t);
}
@@ -1779,7 +1779,7 @@ function SnabbDokumentation({
};
return (
<Panel rubrik="Dokumentera">
<Panel rubrik="Document">
<div className="grid grid-cols-3 gap-2">
{DOKTYPER.map((d) => (
<button
@@ -1813,7 +1813,7 @@ function SnabbDokumentation({
const fil = e.target.files?.[0];
if (!fil) return;
const dataUrl = await skalaNerFoto(fil);
skicka({ typ: "foto", beskrivning: "Foto", dataUrl });
skicka({ typ: "foto", beskrivning: "Photo", dataUrl });
e.target.value = "";
}}
/>
@@ -1843,7 +1843,7 @@ function SnabbDokumentation({
try {
setVideo({ dataUrl: await lasVideo(fil), beskrivning: "" });
} catch (misslyckande) {
setVideoFel(misslyckande instanceof Error ? misslyckande.message : "Videon kunde inte läsas.");
setVideoFel(misslyckande instanceof Error ? misslyckande.message : "The video could not be read.");
}
}}
/>
@@ -1852,10 +1852,10 @@ function SnabbDokumentation({
<div className="mt-3 border border-[#C6C6C6] bg-white p-2">
<video src={video.dataUrl} controls className="mb-2 max-h-48 w-full border border-[#C6C6C6]" />
<TextFalt
label="Vad visar videon? (obligatoriskt — det som låter eller rör sig)"
label="What does the video show? (required — what makes the noise or moves)"
varde={video.beskrivning}
satt={(beskrivning) => setVideo((v) => (v ? { ...v, beskrivning } : v))}
platshallare="T.ex. Motorljud vid tomgång — tickande från topplocket"
platshallare="e.g. Engine noise at idle — ticking from the cylinder head"
rost
/>
<div className="grid grid-cols-2 gap-2">
@@ -1875,7 +1875,7 @@ function SnabbDokumentation({
</div>
</div>
)}
{laserAv && <p className="mt-2 animate-pulse text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av instrumentet </p>}
{laserAv && <p className="mt-2 animate-pulse text-center text-[12px] font-semibold text-[#00437A]">Reading the instrument </p>}
{avlasning && (
<div className="mt-3 border border-[#C6C6C6] bg-white p-2">
{avlasning.demo && (
@@ -1900,7 +1900,7 @@ function SnabbDokumentation({
<StorKnapp variant="sekundar" onClick={() => setAvlasning(null)}>
Förkasta
</StorKnapp>
<StorKnapp onClick={sparaAvlasning}>Spara foto + värden</StorKnapp>
<StorKnapp onClick={sparaAvlasning}>Save photograph + values</StorKnapp>
</div>
</div>
)}
@@ -1912,13 +1912,13 @@ function SnabbDokumentation({
</p>
)}
<TextFalt
label={typ === "matvarde" ? "Vad mättes?" : DOKTYPER.find((d) => d.id === typ)!.label}
label={typ === "matvarde" ? "What was measured?" : DOKTYPER.find((d) => d.id === typ)!.label}
varde={text}
satt={setText}
platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""}
platshallare={typ === "matvarde" ? "e.g. Supply voltage at pin 30" : ""}
rost
/>
{typ === "matvarde" && <TextFalt label="Värde (med enhet)" varde={varde} satt={setVarde} platshallare="T.ex. 13,9 V" />}
{typ === "matvarde" && <TextFalt label="Value (with unit)" varde={varde} satt={setVarde} platshallare="e.g. 13.9 V" />}
<StorKnapp disabled={!text.trim() || (typ === "matvarde" && !varde.trim())} onClick={spara}>
Spara
</StorKnapp>
@@ -1968,7 +1968,7 @@ function OverlamningDialog({
return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div className="max-h-full w-full max-w-2xl overflow-y-auto border border-[#ADADAD] bg-[#F7F7F7] p-4">
<h2 className="mb-3 text-[15px] font-semibold">Överlämningsrapport</h2>
<h2 className="mb-3 text-[15px] font-semibold">Handover report</h2>
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap bg-white p-4 text-[12px] text-[#333333]">
{text}
</pre>
@@ -1977,9 +1977,9 @@ function OverlamningDialog({
Komplettera analysen: risker &amp; osäkerheter
</StorKnapp>
)}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">Systemet sammanfattar </p>}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">Summarizing </p>}
{aiLage === "fel" && (
<p className="mb-3 font-semibold text-[#8B1A1A]">Kunde inte hämtas kräver inloggning. Överlämningen fungerar ändå.</p>
<p className="mb-3 font-semibold text-[#8B1A1A]">Could not be retrieved requires sign-in. The handover works regardless.</p>
)}
{aiLage === "klar" && aiRader.length > 0 && (
<div className="mb-3 bg-white p-4">
@@ -1990,7 +1990,7 @@ function OverlamningDialog({
))}
</div>
)}
<TextFalt label="Lämnas till (valfritt)" varde={till} satt={setTill} platshallare="T.ex. Johan" />
<TextFalt label="Handed to (optional)" varde={till} satt={setTill} platshallare="e.g. Jonas" />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={stang}>
Avbryt
@@ -2010,9 +2010,9 @@ function OverlamningDialog({
}
const NIVA_LABEL: Record<DelningsNiva, string> = {
kund: "Kund",
partner: "Extern partner",
intern: "Intern",
kund: "Customer",
partner: "External partner",
intern: "Internal",
};
// Live Share-behörighetsnivåer: skapa och återkalla delningslänkar.
@@ -2028,7 +2028,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
setDelningar(await hamtaDelningar(arende.id));
setFel("");
} catch {
setFel("Kunde inte hämta delningar — kräver inloggning och att ärendet är synkat.");
setFel("Could not retrieve shares — requires sign-in and a synchronized case.");
}
};
@@ -2054,7 +2054,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
<div key={delning.kod} className="flex items-center justify-between gap-2 border-b border-[#DDDDDD] py-2 last:border-0">
<span className="text-[14px]">
{NIVA_LABEL[delning.niva]}{" "}
{delning.aterkallad && <span className="text-[12px] font-semibold text-[#8B1A1A]">(återkallad)</span>}
{delning.aterkallad && <span className="text-[12px] font-semibold text-[#8B1A1A]">(revoked)</span>}
</span>
{!delning.aterkallad && (
<span className="flex gap-2">
@@ -2091,7 +2091,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
skicka({ typ: "kommentar", text: `Delningslänk skapad (${NIVA_LABEL[niva]}).` });
uppdatera();
} catch {
setFel("Kunde inte skapa delning — kräver inloggning och att ärendet är synkat.");
setFel("Could not create a share — requires sign-in and a synchronized case.");
}
}}
>
@@ -2109,7 +2109,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
function LoggFlik({ arende }: { arende: Arende }) {
return (
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
<Panel rubrik="Work log — append-only, nothing is overwritten">
<ol>
{arende.handelser.map((post) => (
<li key={post.id} className="flex gap-3 border-b border-[#DDDDDD] py-2 last:border-0">
@@ -2162,14 +2162,14 @@ function BriefFlik({
return (
<>
{!b.avslutat && (
<Panel rubrik="Granskning av underlaget">
<Panel rubrik="Review of the evidence">
<p className="mb-2 text-[#333333]">
Låt systemet igenom hela underlaget innan du lämnar det vidare: motsägelser mellan
observationer, luckor i dokumentationen och slutsatser som saknar stöd.
</p>
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Systemet granskar underlaget </p>}
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Reviewing the evidence </p>}
{granskning === "fel" && (
<p className="mb-2 font-semibold text-[#8B1A1A]">Granskningen kunde inte hämtas kräver inloggning. Försök igen.</p>
<p className="mb-2 font-semibold text-[#8B1A1A]">The review could not be retrieved requires sign-in. Try again.</p>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && granskning === "vilar" && (
<div className="mb-2">
@@ -2179,7 +2179,7 @@ function BriefFlik({
</p>
))}
<p className="mt-1 text-[14px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
<span className="font-semibold text-[#00437A]">Next step:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-[11px] text-[#707070]">Senaste analys ({senasteAi.handelse.modell})</p>
</div>
@@ -2189,7 +2189,7 @@ function BriefFlik({
</StorKnapp>
</Panel>
)}
<Panel rubrik="Objekt">
<Panel rubrik="Object">
{b.objekt ? (
<>
<p className="text-[15px] font-semibold">{b.objekt.beskrivning}</p>
@@ -2198,14 +2198,14 @@ function BriefFlik({
{b.ansvarig && <p className="text-[#333333]">Ansvarig tekniker: {b.ansvarig}</p>}
</>
) : (
<p className="text-[#4A5560]">Ej identifierat</p>
<p className="text-[#4A5560]">Not identified</p>
)}
</Panel>
<Panel rubrik="Kundens beskrivning">
<Panel rubrik="Customer's description">
<p className="text-[14px]">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Utförda kontroller">
{b.utfordaKontroller.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
<Panel rubrik="Checks performed">
{b.utfordaKontroller.length === 0 && <p className="text-[#4A5560]">None yet.</p>}
{b.utfordaKontroller.map((k, i) =>
k.undantag ? (
<p key={i} className="py-0.5 text-[14px] text-[#9A6700]">
@@ -2219,14 +2219,14 @@ function BriefFlik({
),
)}
</Panel>
<Panel rubrik="Observationer">
{b.observationer.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
<Panel rubrik="Observations">
{b.observationer.length === 0 && <p className="text-[#4A5560]">None yet.</p>}
{b.observationer.map((o, i) => (
<p key={i} className="py-0.5 text-[14px]"> {o}</p>
))}
</Panel>
{b.hypoteser.length > 0 && (
<Panel rubrik="Hypoteserkräver verifiering">
<Panel rubrik="Hypothesesrequire verification">
{b.hypoteser.map((h, i) => (
<p key={i} className="py-0.5 text-[14px]">
<NivaBadge niva={h.niva} /> {h.text}
@@ -2234,27 +2234,27 @@ function BriefFlik({
))}
</Panel>
)}
<Panel rubrik="Ej kontrollerat">
{b.ejKontrollerat.length === 0 && <p className="text-[#4A5560]">Allt i metodiken är utfört.</p>}
<Panel rubrik="Not checked">
{b.ejKontrollerat.length === 0 && <p className="text-[#4A5560]">Every step in the methodology is complete.</p>}
{b.ejKontrollerat.map((e, i) => (
<p key={i} className="py-0.5 text-[14px] text-[#333333]"> {e}</p>
))}
</Panel>
{!b.avslutat && (
<Panel rubrik="Rekommenderat nästa steg">
<Panel rubrik="Recommended next step">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-[14px]">{i + 1}. {s}</p>
))}
</Panel>
)}
<Panel rubrik="Tillförlitlighet">
<Panel rubrik="Reliability">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-[14px]">
<NivaBadge niva={r.niva} /> {r.text}
</p>
))}
</Panel>
<Panel rubrik="Total arbetstid">
<Panel rubrik="Total labour time">
<p className="text-[17px] font-semibold">{b.totalArbetstid}</p>
</Panel>
</>
@@ -2338,7 +2338,7 @@ function RapportFlik({
{rad.ok ? <IkonCheck /> : rad.kravs ? <IkonKryss /> : <IkonVarning />}
</span>{" "}
{rad.rubrik}
{!rad.kravs && <span className="text-[11px] text-[#707070]"> (rekommenderas)</span>}
{!rad.kravs && <span className="text-[11px] text-[#707070]"> (recommended)</span>}
</p>
{rad.detalj && <p className="pl-6 text-[12px] text-[#707070]">{rad.detalj}</p>}
</div>
@@ -2350,7 +2350,7 @@ function RapportFlik({
</p>
)}
</Panel>
<Panel rubrik="Kundrapport">
<Panel rubrik="Customer report">
<p className="mb-2 text-[#333333]">
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas bilder kan
innehålla uppgifter om andra kunder.
@@ -2364,7 +2364,7 @@ function RapportFlik({
</StorKnapp>
</div>
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
<StorKnapp variant="sekundar"><IkonPunkt farg="#1E6B34" /> Öppna Live Share-vy</StorKnapp>
<StorKnapp variant="sekundar"><IkonPunkt farg="#1E6B34" /> Open Live Share view</StorKnapp>
</Link>
{plattformAktiv() ? (
<DelningsHanterare arende={arende} skicka={skicka} />
@@ -2375,10 +2375,10 @@ function RapportFlik({
className="mt-2"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`);
skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." });
skicka({ typ: "kommentar", text: "Share link copied for an external recipient." });
}}
>
<IkonLank /> Kopiera delningslänk
<IkonLank /> Copy share link
</StorKnapp>
)
)}
@@ -2390,9 +2390,9 @@ function RapportFlik({
rapporten — kund, handläggare, nästa tekniker — ska få bilden
på fem sekunder och sedan kunna gå djupare. Texten är härledd
ur loggen, så den kan inte säga något som inte står där. */}
<Panel rubrik="Sammanfattning">
<Panel rubrik="Summary">
<p className="text-[15px] leading-[24px] text-[#1B1E22]">{sammanfatta(arende).text}</p>
<p className="mt-2 font-mono text-[11px] text-[#4D5662]">ALVA-PROC-0030 · härledd ur händelseloggen</p>
<p className="mt-2 font-mono text-[11px] text-[#4D5662]">ALVA-PROC-0030 · derived from the case log</p>
</Panel>
{/* ALVA-RULE-200 · Teknikerns varför. Placerad före underlaget:
@@ -2402,12 +2402,12 @@ function RapportFlik({
if (!s || s.handelse.typ !== "slutsats") return null;
const h = s.handelse;
return (
<Panel rubrik="Slutsats och motivering">
<Panel rubrik="Closing statement and rationale">
{identitetsRader([
[h.orsakFastställd === false ? "Skäl till att orsaken inte fastställts" : "Motivering", h.motivering],
["Uteslutna alternativ", h.uteslutet],
["Val av åtgärd", h.atgardsval ?? "—"],
["Kvarstående osäkerhet", h.kvarstaende],
[h.orsakFastställd === false ? "Reason the cause could not be established" : "Rationale", h.motivering],
["Dismissed alternatives", h.uteslutet],
["Choice of action", h.atgardsval ?? "—"],
["Remaining uncertainty", h.kvarstaende],
])}
</Panel>
);
@@ -2415,43 +2415,43 @@ function RapportFlik({
{/* Rapportens första sida: ärendeidentiteten — registrerad en gång,
återanvänd här automatiskt. */}
<Panel rubrik="Ärendeinformation">
<Panel rubrik="Case information">
{identitetsRader([
["Ärende", `#${arende.nummer}`],
["Arbetsorder", idn.arbetsorder],
["Claim-/garantinr", idn.claim],
["Skadenummer", idn.skadenummer],
["Ärendetyp", typ],
["Ansvarig tekniker", idn.ansvarig],
["Case", `#${arende.nummer}`],
["Work order", idn.arbetsorder],
["Claim / warranty no.", idn.claim],
["Claim number", idn.skadenummer],
["Case type", typ],
["Responsible technician", idn.ansvarig],
])}
</Panel>
<Panel rubrik="Fordonsinformation">
<Panel rubrik="Vehicle information">
{identitetsRader([
["Personbil", idn.beskrivning],
["Regnr", idn.identifierare],
["Passenger car", idn.beskrivning],
["Reg. no.", idn.identifierare],
["VIN", idn.vin],
["Mätarställning in", matIn],
["Mätarställning ut", matUt],
["Kund", idn.kund],
["Odometer in", matIn],
["Odometer out", matUt],
["Customer", idn.kund],
])}
</Panel>
{/* Beviskedjan: kundens upplevelse, teknikerns verifiering och den
tekniska slutsatsen hålls strikt åtskilda och spårbara. */}
<Panel rubrik="Kundens beskrivning">
<Panel rubrik="Customer's description">
<p className="text-[14px]">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Verifierad observation">
<Panel rubrik="Verified observation">
{repro ? (
<>
<p className="text-[14px] font-semibold">{reproduceringsText(repro.status)}</p>
<p className="text-[13px] text-[#4A5560]">{repro.beskrivning}</p>
</>
) : (
<p className="text-[14px] text-[#4A5560]">Reproducering ej dokumenterad ännu.</p>
<p className="text-[14px] text-[#4A5560]">Reproduction not documented yet.</p>
)}
</Panel>
<Panel rubrik="Felorsaksanalys">
{orsakerLista.length === 0 && <p className="text-[14px] text-[#4A5560]">Felorsaksanalys ej dokumenterad ännu.</p>}
<Panel rubrik="Root cause analysis">
{orsakerLista.length === 0 && <p className="text-[14px] text-[#4A5560]">Root cause analysis not documented yet.</p>}
{orsakerLista.map((p) => {
const h = p.handelse;
if (h.typ !== "felorsak") return null;
@@ -2459,42 +2459,42 @@ function RapportFlik({
<div key={p.id} className="mb-3 border-b border-[#EBEBEB] pb-2 last:mb-0 last:border-0">
<p className="text-[14px] font-semibold">{h.avvikelse}</p>
{identitetsRader([
["Bedömd grundorsak", h.orsaker.join(", ")],
["Underlag för bedömningen", h.underlag.join(", ")],
["Säkerhetsnivå", TILLFORLITLIGHET_LABEL[h.sakerhet]],
["Rekommenderad åtgärd", h.atgard],
["Motivering", h.motivering],
["Stärkande kontroller", h.ytterligareKontroller],
["Assessed root cause", h.orsaker.join(", ")],
["Evidence for the assessment", h.underlag.join(", ")],
["Confidence level", TILLFORLITLIGHET_LABEL[h.sakerhet]],
["Recommended action", h.atgard],
["Rationale", h.motivering],
["Strengthening checks", h.ytterligareKontroller],
])}
</div>
);
})}
</Panel>
{(forslagRader.length > 0 || beslutRad) && (
<Panel rubrik="Åtgärdsförslag och kundens besked">
<Panel rubrik="Proposed action and customer decision">
{forslagRader.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<div key={p.id}>
{identitetsRader([
["Föreslagen åtgärd", h.beskrivning],
["Uppskattad kostnad", h.uppskattadKostnad],
["Uppskattad tid", h.uppskattadTid],
["Proposed action", h.beskrivning],
["Estimated cost", h.uppskattadKostnad],
["Estimated time", h.uppskattadTid],
])}
</div>
);
})}
{beslutRad &&
identitetsRader([
["Kundens besked", KUNDBESLUT_LABEL[beslutRad.beslut]],
["Lämnat via", beslutRad.kanal],
["Kommentar", beslutRad.kommentar],
["Customer decision", KUNDBESLUT_LABEL[beslutRad.beslut]],
["Given via", beslutRad.kanal],
["Comment", beslutRad.kommentar],
])}
</Panel>
)}
<Panel rubrik="Utförd åtgärd och verifiering">
{atgardsRader.length === 0 && <p className="text-[14px] text-[#4A5560]">Ingen åtgärd dokumenterad ännu.</p>}
<Panel rubrik="Action performed and verification">
{atgardsRader.length === 0 && <p className="text-[14px] text-[#4A5560]">No action documented yet.</p>}
{atgardsRader.map((p) => {
const h = p.handelse;
if (h.typ !== "atgard_utford") return null;
@@ -2502,8 +2502,8 @@ function RapportFlik({
<div key={p.id} className="mb-2">
{h.utford ? (
identitetsRader([
["Utförd åtgärd", h.beskrivning],
["Delar", h.delar],
["Action performed", h.beskrivning],
["Parts", h.delar],
])
) : (
<p className="text-[14px] text-[#9A6700]">Ingen åtgärd utförd {h.motivering}</p>
@@ -2513,12 +2513,12 @@ function RapportFlik({
})}
{kkRad &&
identitetsRader([
["Kvalitetskontroll", KVALITETSKONTROLL_LABEL[kkRad.resultat]],
["Verifiering", kkRad.beskrivning],
["Quality check", KVALITETSKONTROLL_LABEL[kkRad.resultat]],
["Verification", kkRad.beskrivning],
])}
</Panel>
<Panel rubrik="Sammanfattning">
<p className="text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
<Panel rubrik="Summary">
<p className="text-[14px]">Total labour time: <span className="font-semibold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && (
<div className="mt-2">
{fordelning.map((r) => (
@@ -2527,7 +2527,7 @@ function RapportFlik({
</div>
)}
</Panel>
<Panel rubrik="Tidslinje">
<Panel rubrik="Timeline">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-[13px]">
<span className="font-mono font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>{" "}
@@ -2536,7 +2536,7 @@ function RapportFlik({
))}
</Panel>
{klipp.length > 0 && (
<Panel rubrik="Dokumenterad video">
<Panel rubrik="Documented video">
{klipp.map((v, i) => (
<figure key={i} className="mb-2">
<Klipp bilaga={v.bilaga} className="w-full border border-[#C6C6C6]" />
@@ -2546,7 +2546,7 @@ function RapportFlik({
</Panel>
)}
{bilder.length > 0 && (
<Panel rubrik="Dokumenterade bilder">
<Panel rubrik="Documented images">
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
@@ -2558,7 +2558,7 @@ function RapportFlik({
</Panel>
)}
{!b.avslutat || b.ejKontrollerat.length > 0 ? (
<Panel rubrik="Rekommenderade nästa steg">
<Panel rubrik="Recommended next steps">
{(b.avslutat ? b.ejKontrollerat : b.rekommenderatNastaSteg).map((s, i) => (
<p key={i} className="py-0.5 text-[14px]">{i + 1}. {s}</p>
))}
@@ -2601,7 +2601,7 @@ function Felanmalan({ arende, metodik }: { arende: Arende; metodik: Metodik | nu
try {
const { anmalSupport, plattformAktiv } = await import("@/felsokning/plattform");
if (!plattformAktiv()) {
setUtfall({ fel: "Ingen plattform är konfigurerad — anmälan kan inte skickas härifrån." });
setUtfall({ fel: "No platform is configured — the report cannot be sent from here." });
return;
}
const { beteckning } = await anmalSupport({
@@ -2633,42 +2633,42 @@ function Felanmalan({ arende, metodik }: { arende: Arende; metodik: Metodik | nu
onClick={() => setOppen(true)}
className="text-[12px] uppercase tracking-wide text-[#4A5560] underline"
>
Rapportera ett problem med systemet
Report a problem with the system
</button>
</p>
);
}
return (
<Panel rubrik="Rapportera ett problem med systemet">
<Panel rubrik="Report a problem with the system">
<p className="mb-2 text-[12px] text-[#707070]">
Ärende, metodik och plattformsversion följer med automatiskt. Inget om fordonet eller kunden
skickas.
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{[
["felanmalan", "Fel"],
["fraga", "Fråga"],
["forbattring", "Förbättring"],
["felanmalan", "Fault"],
["fraga", "Question"],
["forbattring", "Improvement"],
].map(([id, etikett]) => (
<StorKnapp key={id} variant={typ === id ? "primar" : "sekundar"} onClick={() => setTyp(id)}>
{etikett}
</StorKnapp>
))}
</div>
<TextFalt label="Vad gäller det?" varde={rubrik} satt={setRubrik} platshallare="Kort rubrik" />
<TextFalt label="What is it about?" varde={rubrik} satt={setRubrik} platshallare="Short title" />
<TextFalt
label="Vad hände, och vad väntade du dig?"
label="What happened, and what did you expect?"
varde={text}
satt={setText}
platshallare="Den som svarar ser inte din skärm."
platshallare="Whoever answers cannot see your screen."
flerRad
/>
{utfall.ok && <p className="mt-2 text-[13px] text-[#1E6B34]">Anmäld som {utfall.ok}.</p>}
{utfall.fel && <p className="mt-2 text-[13px] text-[#8B1A1A]">{utfall.fel}</p>}
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp onClick={skicka} disabled={skickar || rubrik.trim().length < 5 || text.trim().length < 20}>
{skickar ? "Skickar" : "Skicka anmälan"}
{skickar ? "Sending" : "Send report"}
</StorKnapp>
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Stäng
@@ -2695,16 +2695,16 @@ function Ritningsstampel({ arende }: { arende: Arende }) {
const paket = aktivtRegelpaket();
const avslutad = [...arende.handelser].reverse().find((p) => p.handelse.typ === "arende_avslutat");
const falt: [string, string][] = [
["Beteckning", arendebeteckning(arende.nummer)],
["Regelpaket", paket.version],
["Evidensmodell", `ECM ${ECM_VERSION}`],
["Designation", arendebeteckning(arende.nummer)],
["Rule package", paket.version],
["Evidence model", `ECM ${ECM_VERSION}`],
// Versionen VID AVSLUTET, inte dagens. Stämpeln läste tidigare den
// aktuella konstanten, så ett ärende stängt under 1.0 visade 3.1 —
// vilket är exakt det stämpelns beskrivning ovan säger att den inte
// ska göra. Ett öppet ärende visar dagens, eftersom det är sant då.
["Plattform", avslutad ? versionVidAvslut(avslutad.handelse) : PLATTFORMSVERSION],
["Platform", avslutad ? versionVidAvslut(avslutad.handelse) : PLATTFORMSVERSION],
["Status", avslutad ? "Closed" : "In progress"],
["Utgåva", avslutad ? new Date(avslutad.tidpunkt).toISOString().slice(0, 10) : "—"],
["Release", avslutad ? new Date(avslutad.tidpunkt).toISOString().slice(0, 10) : "—"],
];
return (
@@ -23,9 +23,9 @@ import { tidDatum, tidKlockslag } from "@/felsokning/format";
type Filter = "alla" | "pagaende" | "klara";
const ROLL_LABEL: Record<PlattformRoll, string> = {
tekniker: "Tekniker",
arbetsledare: "Arbetsledare",
admin: "Systemadministratör",
tekniker: "Technician",
arbetsledare: "Supervisor",
admin: "System administrator",
};
// Självhostat läge: inloggning mot plattformstjänsten i klustret.
@@ -43,7 +43,7 @@ function PlattformInloggning() {
if (konto) {
return (
<>
<Panel rubrik="Plattformskonto">
<Panel rubrik="Platform account">
<div className="flex items-center justify-between gap-3">
<p className="text-[14px]">
<span className="font-semibold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
@@ -61,12 +61,12 @@ function PlattformInloggning() {
</div>
{(konto.roll === "arbetsledare" || konto.roll === "admin") && (
<Link to="/felsokning/oversikt" className="mt-3 block">
<StorKnapp variant="sekundar"><IkonDiagram /> Organisationsöversikt</StorKnapp>
<StorKnapp variant="sekundar"><IkonDiagram /> Organization overview</StorKnapp>
</Link>
)}
{konto.roll === "admin" && (
<Link to="/felsokning/installningar" className="mt-2 block">
<StorKnapp variant="sekundar"><IkonKugghjul /> Inställningar för organisationen</StorKnapp>
<StorKnapp variant="sekundar"><IkonKugghjul /> Organization settings</StorKnapp>
</Link>
)}
</Panel>
@@ -76,7 +76,7 @@ function PlattformInloggning() {
}
return (
<Panel rubrik="Plattformskonto">
<Panel rubrik="Platform account">
{lage === "stangd" ? (
<>
<p className="mb-2 text-[#333333]">
@@ -85,9 +85,9 @@ function PlattformInloggning() {
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
Skapa organisation
Create organization
</StorKnapp>
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
<StorKnapp onClick={() => setLage("loggaIn")}>Sign in</StorKnapp>
</div>
</>
) : (
@@ -102,18 +102,18 @@ function PlattformInloggning() {
: await registreraPlattform(epost, losenord, nyttNamn, organisation),
);
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
setFel(misslyckande instanceof Error ? misslyckande.message : "Something went wrong.");
}
}}
>
{lage === "registrera" && (
<>
<TextFalt label="Organisation" varde={organisation} satt={setOrganisation} platshallare="T.ex. Verkstad Syd AB" />
<TextFalt label="Ditt namn" varde={nyttNamn} satt={setNyttNamn} platshallare="T.ex. Anna" />
<TextFalt label="Organisation" varde={organisation} satt={setOrganisation} platshallare="e.g. Southside Motors Ltd" />
<TextFalt label="Your name" varde={nyttNamn} satt={setNyttNamn} platshallare="e.g. Anna" />
</>
)}
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
<TextFalt label="E-mail" varde={epost} satt={setEpost} platshallare="anna@workshop.example" />
<TextFalt label="Password (at least 8 characters)" varde={losenord} satt={setLosenord} losenord />
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
@@ -127,7 +127,7 @@ function PlattformInloggning() {
(lage === "registrera" && (!nyttNamn.trim() || !organisation.trim()))
}
>
{lage === "loggaIn" ? "Logga in" : "Skapa organisation"}
{lage === "loggaIn" ? "Sign in" : "Create organization"}
</StorKnapp>
</div>
</form>
@@ -151,13 +151,13 @@ function AnvandarAdmin() {
try {
setLista(await hamtaAnvandare());
} catch {
setFel("Kunde inte hämta användare.");
setFel("Could not retrieve users.");
}
};
if (!oppen) {
return (
<Panel rubrik="Användare">
<Panel rubrik="Users">
<StorKnapp
variant="sekundar"
onClick={() => {
@@ -172,7 +172,7 @@ function AnvandarAdmin() {
}
return (
<Panel rubrik="Användare">
<Panel rubrik="Users">
{lista.map((anv) => (
<div
key={anv.id}
@@ -184,7 +184,7 @@ function AnvandarAdmin() {
</span>{" "}
· {ROLL_LABEL[anv.roll]} <span className="text-[#707070]">{anv.epost}</span>
{anv.aktiv === false && (
<span className="ml-1 text-[12px] font-semibold text-[#8B1A1A]">Avstängd</span>
<span className="ml-1 text-[12px] font-semibold text-[#8B1A1A]">Disabled</span>
)}
</p>
<button
@@ -200,11 +200,11 @@ function AnvandarAdmin() {
await sattKontoAktiv(anv.id, anv.aktiv === false);
await uppdatera();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
setFel(misslyckande instanceof Error ? misslyckande.message : "Something went wrong.");
}
}}
>
{anv.aktiv === false ? "Öppna igen" : "Stäng av"}
{anv.aktiv === false ? "Reopen" : "Disable"}
</button>
</div>
))}
@@ -223,13 +223,13 @@ function AnvandarAdmin() {
setLosenord("");
await uppdatera();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
setFel(misslyckande instanceof Error ? misslyckande.message : "Something went wrong.");
}
}}
>
<TextFalt label="Namn" varde={namn} satt={setNamn} />
<TextFalt label="E-post" varde={epost} satt={setEpost} />
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
<TextFalt label="Name" varde={namn} satt={setNamn} />
<TextFalt label="E-mail" varde={epost} satt={setEpost} />
<TextFalt label="Password (at least 8 characters)" varde={losenord} satt={setLosenord} losenord />
<div className="mb-3 grid grid-cols-3 gap-2">
{(Object.keys(ROLL_LABEL) as PlattformRoll[]).map((valbar) => (
<button
@@ -270,8 +270,8 @@ export default function Arendelista() {
if (!anvandare) {
return (
<FelsokningSkal rubrik="Guidad Felsökning">
<Panel rubrik="Vem arbetar?">
<FelsokningSkal rubrik="Guided Diagnostics">
<Panel rubrik="Who is working?">
<p className="mb-3 text-[14px] text-[#333333]">
Allt arbete loggas med användare och tidpunkt. Ange ditt namn för att börja.
</p>
@@ -281,7 +281,7 @@ export default function Arendelista() {
if (namn.trim()) sattAnvandare(namn);
}}
>
<TextFalt label="Namn" varde={namn} satt={setNamn} platshallare="T.ex. Anna" />
<TextFalt label="Name" varde={namn} satt={setNamn} platshallare="e.g. Anna" />
<StorKnapp type="submit" disabled={!namn.trim()}>
Fortsätt
</StorKnapp>
@@ -297,18 +297,18 @@ export default function Arendelista() {
const lista = filter === "pagaende" ? pagaende : filter === "klara" ? klara : alla;
const FILTER: { id: Filter; label: string; antal: number }[] = [
{ id: "alla", label: "Alla", antal: alla.length },
{ id: "pagaende", label: "Pågående", antal: pagaende.length },
{ id: "klara", label: "Klara", antal: klara.length },
{ id: "alla", label: "All", antal: alla.length },
{ id: "pagaende", label: "In progress", antal: pagaende.length },
{ id: "klara", label: "Complete", antal: klara.length },
];
return (
<FelsokningSkal
rubrik="Guidad Felsökning"
rubrik="Guided Diagnostics"
hoger={<span className="text-[12px] font-semibold text-[#A9C3DE]">{anvandare}</span>}
>
<Link to="/felsokning/nytt">
<StorKnapp className="mb-4">+ Nytt ärende</StorKnapp>
<StorKnapp className="mb-4">+ New case</StorKnapp>
</Link>
{alla.length > 0 && (
@@ -328,7 +328,7 @@ export default function Arendelista() {
)}
{alla.length === 0 && (
<Panel rubrik="Kom igång">
<Panel rubrik="Get started">
<p className="mb-3 text-[14px] text-[#333333]">
Inga ärenden ännu. Starta med att identifiera ett objekt eller utforska ett färdigt demoärende
med komplett arbetslogg, brief och kundrapport.
@@ -340,18 +340,18 @@ export default function Arendelista() {
)}
{lista.length === 0 && alla.length > 0 && (
<p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i det här filtret.</p>
<p className="text-center text-[14px] text-[#4A5560]">No cases in this filter.</p>
)}
{plattformAktiv() && <PlattformInloggning />}
{!plattformKonto() && (
<Link to="/felsokning/installningar" className="mb-4 block">
<StorKnapp variant="sekundar"><IkonKugghjul /> Inställningar</StorKnapp>
<StorKnapp variant="sekundar"><IkonKugghjul /> Settings</StorKnapp>
</Link>
)}
<Panel rubrik="Beslutsstöd">
<Panel rubrik="Decision support">
<p className="text-[#333333]">
När du är inloggad hjälper systemet dig medan du arbetar: föreslår nästa steg utifrån det du
dokumenterar, granskar hela ärendet begäran och skriver utkast till överlämningen. Den
@@ -379,10 +379,10 @@ export default function Arendelista() {
avslutat ? "bg-[#8A94A0] text-white" : "bg-[#00437A] text-white"
}`}
>
{avslutat ? "Avslutat" : "Pågående"}
{avslutat ? "Closed" : "In progress"}
</span>
</div>
<p className="mt-1 text-[15px] font-semibold">{o ? o.beskrivning : "Okänt objekt"}</p>
<p className="mt-1 text-[15px] font-semibold">{o ? o.beskrivning : "Unknown object"}</p>
{o && <p className="text-[12px] font-semibold text-[#4A5560]">{o.identifierare}{o.kund ? ` · ${o.kund}` : ""}</p>}
{fel && <p className="mt-2 text-[14px] text-[#333333]">{fel}</p>}
<p className="mt-2 text-[12px] text-[#707070]">
@@ -20,8 +20,8 @@ export default function DelatArende() {
if (!arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<p className="text-[14px] text-[#333333]">Ärendet är inte tillgängligt.</p>
<FelsokningSkal rubrik="Shared case">
<p className="text-[14px] text-[#333333]">The case is not available.</p>
</FelsokningSkal>
);
}
@@ -30,7 +30,7 @@ export default function DelatArende() {
<DelatArendeVy
arende={arende}
nu={nu}
notis="Skrivskyddad livevy — uppdateras automatiskt när verkstaden registrerar ny information. Detta är förhandsvisningen; den externa delningslänken visar samma vy."
notis="Read-only live view — updates automatically as the workshop records new information. This is the preview; the external share link shows the same view."
/>
);
}
@@ -84,7 +84,7 @@ function Integrationer() {
if (leverantorer.length === 0) return null;
return (
<Panel rubrik="Märkesspecifika kopplingar">
<Panel rubrik="Brand-specific connectors">
<p className="mb-2 text-[12px] text-[#707070]">
Organisationens egna avtal hos tillverkare och dataleverantörer. Uppgifterna lagras krypterat
plattformen och används bara av servern de skickas aldrig till teknikernas enheter.
@@ -114,7 +114,7 @@ function Integrationer() {
)}
</div>
<span className={`shrink-0 text-[11px] font-semibold ${finns ? "text-[#1E6B34]" : "text-[#707070]"}`}>
{finns ? "Konfigurerad" : "Ej konfigurerad"}
{finns ? "Configured" : "Not configured"}
</span>
</div>
@@ -123,7 +123,7 @@ function Integrationer() {
{lev.falt.map((falt) => (
<TextFalt
key={falt.nyckel}
label={`${falt.etikett}${falt.hemlig ? " (lagras krypterat)" : ""}`}
label={`${falt.etikett}${falt.hemlig ? " (stored encrypted)" : ""}`}
varde={varden[falt.nyckel] ?? ""}
satt={(v) => setVarden((f) => ({ ...f, [falt.nyckel]: v }))}
losenord={falt.hemlig}
@@ -145,7 +145,7 @@ function Integrationer() {
setSparat(lev.namn);
ladda();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara.");
setFel(misslyckande instanceof Error ? misslyckande.message : "Could not save.");
}
}}
>
@@ -172,7 +172,7 @@ function Integrationer() {
setVarden(start);
}}
>
{finns ? "Uppdatera uppgifter" : "Lägg till uppgifter"}
{finns ? "Update details" : "Add details"}
</StorKnapp>
{finns && (
<StorKnapp
@@ -210,7 +210,7 @@ export default function Installningar() {
if (!farAndra) {
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<FelsokningSkal rubrik="Settings" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel>
<p className="text-[14px] text-[#333333]">
Organisationens inställningar hanteras av er systemadministratör.
@@ -223,7 +223,7 @@ export default function Installningar() {
const spara = async () => {
if (inst.objekttyper.length === 0 || inst.identifieringsmetoder.length === 0) {
setStatus("fel");
setFelText("Minst ett alternativ måste vara valt i varje lista.");
setFelText("At least one option must be selected in each list.");
return;
}
setStatus("sparar");
@@ -233,7 +233,7 @@ export default function Installningar() {
setStatus("sparat");
} catch (misslyckande) {
setStatus("fel");
setFelText(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara.");
setFelText(misslyckande instanceof Error ? misslyckande.message : "Could not save.");
}
};
@@ -243,17 +243,17 @@ export default function Installningar() {
};
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<FelsokningSkal rubrik="Settings" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel>
<p className="text-[#333333]">
Välj vad som visas när ett nytt ärende startas.{" "}
{inloggad
? "Valet gäller alla i organisationen."
: "I lokalt läge gäller valet den här enheten; inloggad systemadministratör styr hela organisationen."}
? "The setting applies to everyone in the organization."
: "In local mode the setting applies to this device; a signed-in system administrator governs the whole organization."}
</p>
</Panel>
<Panel rubrik="Objekttyper">
<Panel rubrik="Object types">
<Vallista
alla={ALLA_OBJEKTTYPER}
valda={inst.objekttyper}
@@ -261,7 +261,7 @@ export default function Installningar() {
/>
</Panel>
<Panel rubrik="Identifieringsmetoder">
<Panel rubrik="Identification methods">
<Vallista
alla={ALLA_IDENTIFIERINGSMETODER}
valda={inst.identifieringsmetoder}
@@ -272,9 +272,9 @@ export default function Installningar() {
{inloggad && <Integrationer />}
{status === "fel" && <p className="mb-3 font-semibold text-[#8B1A1A]">{felText}</p>}
{status === "sparat" && <p className="mb-3 font-semibold text-[#1E6B34]"> Sparat</p>}
{status === "sparat" && <p className="mb-3 font-semibold text-[#1E6B34]"> Saved</p>}
<StorKnapp disabled={status === "sparar"} onClick={spara}>
{status === "sparar" ? "Sparar …" : "Spara inställningar"}
{status === "sparar" ? "Saving …" : "Save settings"}
</StorKnapp>
</FelsokningSkal>
);
@@ -87,10 +87,10 @@ export default function NyttArende() {
const kandidater = resultat?.tolkning.varden.map((v) => v.varde) ?? [];
const traff = kandidater.map((v) => tolkaKod(v)).find((k): k is AvlastKod => !!k);
if (traff) anvandKod(traff);
else if (!resultat) setKodFel("Avläsning av typskylt kräver inloggningange identiteten manuellt.");
else setKodFel("Ingen identifierare kunde läsas ur bilden — försök igen eller ange manuellt.");
else if (!resultat) setKodFel("Reading the type plate requires sign-in — enter the identity manually.");
else setKodFel("No identifier could be read from the image — try again or enter it manually.");
} catch {
setKodFel("Avläsningen kunde inte nås — ange identiteten manuellt.");
setKodFel("The reader could not be reached — enter the identity manually.");
}
setLaserTypskylt(false);
};
@@ -110,7 +110,7 @@ export default function NyttArende() {
const resultat = await tolkaArbetsorder(foto);
if (resultat) {
if (resultat.falt.length === 0) {
setSkanningsFel("Inga fält kunde läsas ur bilden — ta ett tydligare foto eller fyll i manuellt.");
setSkanningsFel("No fields could be read from the image — take a clearer photograph or fill in manually.");
} else {
setTolkning({ foto, falt: resultat.falt, modell: resultat.modell });
setSteg("granska");
@@ -120,7 +120,7 @@ export default function NyttArende() {
setSteg("granska");
}
} catch {
setSkanningsFel("Dokumenttolkningen kunde inte nås — försök igen eller fyll i manuellt.");
setSkanningsFel("Document interpretation could not be reached — try again or fill in manually.");
}
setSkannar(false);
};
@@ -136,10 +136,10 @@ export default function NyttArende() {
const vin = v("fordon_vin");
const beskr = [v("fordon_marke"), v("fordon_modell"), v("fordon_arsmodell")].filter(Boolean).join(" ");
const objektet: Objekt = {
typ: inst.objekttyper.includes("Personbil") ? "Personbil" : inst.objekttyper[0],
identifierare: (regnr ?? vin ?? "OKÄND").toUpperCase(),
identifieringsmetod: regnr ? "Regnr" : vin ? "VIN" : "Manuell inmatning",
beskrivning: beskr || "Se arbetsorder",
typ: inst.objekttyper.includes("Passenger car") ? "Passenger car" : inst.objekttyper[0],
identifierare: (regnr ?? vin ?? "UNKNOWN").toUpperCase(),
identifieringsmetod: regnr ? "Reg. no." : vin ? "VIN" : "Manual entry",
beskrivning: beskr || "View work order",
kund: v("kund_namn"),
// Ärendeidentiteten: registreras en gång här, återanvänds i alla vyer.
vin: vin?.toUpperCase(),
@@ -148,7 +148,7 @@ export default function NyttArende() {
claim: v("ao_claim"),
skadenummer: v("ao_skadenummer"),
};
const felText = v("felbeskrivning") ?? "Se skannad arbetsorder";
const felText = v("felbeskrivning") ?? "View the scanned work order";
let metodikId: string | undefined;
if (valjMetodik(felText).id === "generisk") {
metodikId = (await valjMetodikMedAi(felText)) ?? undefined;
@@ -174,8 +174,8 @@ export default function NyttArende() {
if (steg === "start") {
return (
<FelsokningSkal rubrik="Starta ärende" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Skanna arbetsorder">
<FelsokningSkal rubrik="Start case" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel rubrik="Scan work order">
<p className="mb-3 text-[#333333]">
Fota arbetsorderns framsida. Systemet läser kund-, fordons- och ärendeuppgifter automatiskt
du granskar bara osäkra fält och trycker Starta diagnos.
@@ -193,7 +193,7 @@ export default function NyttArende() {
}}
/>
{skannar ? (
<p className="animate-pulse py-2 text-center font-semibold text-[#00437A]">Tolkar dokumentet </p>
<p className="animate-pulse py-2 text-center font-semibold text-[#00437A]">Interpreting the document </p>
) : (
<StorKnapp onClick={() => skanRef.current?.click()}><IkonKamera /> Skanna arbetsorder</StorKnapp>
)}
@@ -212,8 +212,8 @@ export default function NyttArende() {
return (
<FelsokningSkal
bred
rubrik="Granska tolkad arbetsorder"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
rubrik="Review the interpreted work order"
tillbaka={{ till: "/felsokning", text: "Cases" }}
>
{tolkning.demo && (
<p className="mb-3 border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
@@ -236,8 +236,8 @@ export default function NyttArende() {
if (steg === "identifiera") {
return (
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Objekttyp">
<FelsokningSkal rubrik="Identify object" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel rubrik="Object type">
<div className="grid grid-cols-2 gap-2">
{inst.objekttyper.map((t) => (
<StorKnapp key={t} variant={t === typ ? "primar" : "sekundar"} onClick={() => setTyp(t)}>
@@ -246,7 +246,7 @@ export default function NyttArende() {
))}
</div>
</Panel>
<Panel rubrik="Identifieringsmetod">
<Panel rubrik="Identification method">
<div className="grid grid-cols-2 gap-2">
{inst.identifieringsmetoder.map((m) => (
<StorKnapp key={m} variant={m === metod ? "primar" : "sekundar"} onClick={() => setMetod(m)}>
@@ -258,7 +258,7 @@ export default function NyttArende() {
QR-kod, streckkod och typskylt kan läsas av i nästa steg eller ange identiteten manuellt.
</p>
</Panel>
<Panel rubrik="Läs av identiteten">
<Panel rubrik="Read the identity">
<input
ref={typskyltRef}
type="file"
@@ -289,7 +289,7 @@ export default function NyttArende() {
</StorKnapp>
)}
<StorKnapp variant="sekundar" disabled={laserTypskylt} onClick={() => typskyltRef.current?.click()}>
<IkonKamera /> {laserTypskylt ? "Läser av …" : "Fotografera typskylt"}
<IkonKamera /> {laserTypskylt ? "Reading …" : "Photograph the type plate"}
</StorKnapp>
</div>
)}
@@ -301,10 +301,10 @@ export default function NyttArende() {
)}
{kodFel && <p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">{kodFel}</p>}
</Panel>
<Panel rubrik="Objektets identitet">
<TextFalt label={metod} varde={identifierare} satt={setIdentifierare} platshallare="T.ex. ABC123" />
<TextFalt label="Beskrivning (märke, modell, år)" varde={beskrivning} satt={setBeskrivning} platshallare="T.ex. Volvo XC60 D4 2019" />
<TextFalt label="Kund (valfritt)" varde={kund} satt={setKund} platshallare="T.ex. Anders Svensson" />
<Panel rubrik="Object identity">
<TextFalt label={metod} varde={identifierare} satt={setIdentifierare} platshallare="e.g. ABC123" />
<TextFalt label="Description (make, model, year)" varde={beskrivning} satt={setBeskrivning} platshallare="T.ex. Volvo XC60 D4 2019" />
<TextFalt label="Customer (optional)" varde={kund} satt={setKund} platshallare="e.g. Alex Meyer" />
<StorKnapp disabled={!identifierare.trim()} onClick={() => setSteg("bekrafta")}>
Fortsätt
</StorKnapp>
@@ -315,7 +315,7 @@ export default function NyttArende() {
if (steg === "bekrafta") {
return (
<FelsokningSkal rubrik="Bekräfta objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<FelsokningSkal rubrik="Confirm object" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel>
<p className="text-[12px] font-semibold uppercase tracking-widest text-[#4A5560]">{objektet.typ}</p>
<p className="mt-1 text-[20px] font-semibold">{objektet.beskrivning}</p>
@@ -327,7 +327,7 @@ export default function NyttArende() {
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
Ändra
</StorKnapp>
<StorKnapp onClick={() => setSteg("felbeskrivning")}>Rätt objekt fortsätt</StorKnapp>
<StorKnapp onClick={() => setSteg("felbeskrivning")}>Correct object continue</StorKnapp>
</div>
</FelsokningSkal>
);
@@ -336,13 +336,13 @@ export default function NyttArende() {
const metodik = fel.trim() ? valjMetodik(fel) : undefined;
return (
<FelsokningSkal rubrik="Beskriv felet" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<FelsokningSkal rubrik="Describe the fault" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel rubrik={`${objektet.beskrivning} · ${objektet.identifierare}`}>
<TextFalt
label="Kundens/användarens felbeskrivning"
label="Customer's or user's fault description"
varde={fel}
satt={setFel}
platshallare="T.ex. Bilen vibrerar runt 88 km/h"
platshallare="e.g. The car vibrates at around 88 km/h"
flerRad
rost
/>
@@ -365,7 +365,7 @@ export default function NyttArende() {
navigate(`/felsokning/arende/${id}`);
}}
>
{startar ? "Startar …" : "Starta arbetslogg"}
{startar ? "Starting …" : "Start work log"}
</StorKnapp>
</Panel>
</FelsokningSkal>
@@ -396,16 +396,16 @@ function GranskaTolkning({
const obekraftade = rader.filter((r) => !r.bekraftad);
const vald = rader.find((r) => r.id === markerad);
const grupper: ArbetsorderGrupp[] = ["Kund", "Personbil", "Verkstad", "Ärende"];
const grupper: ArbetsorderGrupp[] = ["Customer", "Vehicle", "Workshop", "Case"];
const uppdatera = (id: string, andring: Partial<(typeof rader)[number]>) =>
setRader((r) => r.map((rad) => (rad.id === id ? { ...rad, ...andring } : rad)));
return (
<div className="lg:grid lg:grid-cols-2 lg:items-start lg:gap-3">
<Panel rubrik="Skannad arbetsorder">
<Panel rubrik="Scanned work order">
<div className="relative">
<img src={foto} alt="Skannad arbetsorder" className="w-full border border-[#C6C6C6]" />
<img src={foto} alt="Scanned work order" className="w-full border border-[#C6C6C6]" />
{vald?.omrade && (
<div
className="pointer-events-none absolute border-2 border-[#00437A] bg-[#00437A]/15"
@@ -421,7 +421,7 @@ function GranskaTolkning({
<p className="mt-1 text-[11px] text-[#707070]">
{vald?.omrade
? `Markerat: ${vald.etikett} (ungefärlig position)`
: "Klicka på ett fält för att se var i dokumentet värdet hittades."}
: "Select a field to see where in the document the value was found."}
</p>
</Panel>
@@ -488,7 +488,7 @@ function GranskaTolkning({
Skanna om
</StorKnapp>
<StorKnapp disabled={obekraftade.length > 0 || startar} onClick={() => vidStart(rader)}>
{startar ? "Startar …" : "Starta diagnos"}
{startar ? "Starting …" : "Start diagnosis"}
</StorKnapp>
</div>
</div>
@@ -46,7 +46,7 @@ function Omfordela({ rad, vidKlar }: { rad: OversiktsRad; vidKlar: () => void })
}
return (
<div className="col-span-2 border border-[#C6C6C6] bg-white p-2">
<p className="mb-2 text-[12px] font-semibold uppercase text-[#4A5560]">Ny ansvarig tekniker</p>
<p className="mb-2 text-[12px] font-semibold uppercase text-[#4A5560]">New responsible technician</p>
<div className="grid grid-cols-2 gap-2">
{personer.map((person) => (
<button
@@ -92,14 +92,14 @@ export default function Oversikt() {
if (!behorig) return;
hamtaOversikt()
.then(setRader)
.catch(() => setFel("Kunde inte hämta översikten — kontrollera inloggningen."));
.catch(() => setFel("Could not retrieve the overview — check the sign-in."));
// Flottdata: återkommande felorsaker över organisationen.
hamtaFelorsaksstatistik().then(setFelorsaker).catch(() => setFelorsaker([]));
}, [behorig]);
if (!behorig) {
return (
<FelsokningSkal rubrik="Organisationversikt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<FelsokningSkal rubrik="Organization overview" tillbaka={{ till: "/felsokning", text: "Cases" }}>
<Panel>
<p className="text-[14px] text-[#333333]">
Översikten kräver arbetsledar- eller administratörsbehörighet plattformen.
@@ -134,7 +134,7 @@ export default function Oversikt() {
}
navigate(`/felsokning/arende/${rad.id}`);
} catch {
setFel("Kunde inte hämta ärendet.");
setFel("Could not retrieve the case.");
}
};
@@ -151,28 +151,28 @@ export default function Oversikt() {
return (
<FelsokningSkal
rubrik="Organisationversikt"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
rubrik="Organization overview"
tillbaka={{ till: "/felsokning", text: "Cases" }}
hoger={<span className="text-[12px] font-semibold text-[#A9C3DE]">{konto?.organisation}</span>}
>
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
{!rader && !fel && <p className="animate-pulse text-[14px] text-[#4A5560]">Hämtar </p>}
{!rader && !fel && <p className="animate-pulse text-[14px] text-[#4A5560]">Retrieving </p>}
{rader && (
<>
<Panel rubrik="Statistik">
<Panel rubrik="Statistics">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-[20px] font-semibold">{rader.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Totalt</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Total</p>
</div>
<div>
<p className="text-[20px] font-semibold text-[#00437A]">{pagaende.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Pågående</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">In progress</p>
</div>
<div>
<p className="text-[20px] font-semibold">{avslutade.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Avslutade</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Closed</p>
</div>
</div>
{avslutade.length > 0 && (
@@ -183,7 +183,7 @@ export default function Oversikt() {
</Panel>
{felorsaker.length > 0 && (
<Panel rubrik="Felorsaksstatistikåterkommande fel i organisationen">
<Panel rubrik="Root cause statisticsrecurring faults in the organization">
{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>
@@ -200,7 +200,7 @@ export default function Oversikt() {
</Panel>
)}
{rader.length === 0 && <p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i organisationen ännu.</p>}
{rader.length === 0 && <p className="text-center text-[14px] text-[#4A5560]">No cases in the organization yet.</p>}
{rader.map((rad) => (
<div key={rad.id} className="mb-3 border border-[#C6C6C6] bg-[#F7F7F7] p-4">
@@ -213,10 +213,10 @@ export default function Oversikt() {
rad.avslutat ? "bg-[#8A94A0] text-white" : "bg-[#00437A] text-white"
}`}
>
{rad.avslutat ? "Avslutat" : "Pågående"}
{rad.avslutat ? "Closed" : "In progress"}
</span>
</div>
<p className="mt-1 text-[15px] font-semibold">{rad.objekt ?? "Okänt objekt"}</p>
<p className="mt-1 text-[15px] font-semibold">{rad.objekt ?? "Unknown object"}</p>
{rad.felbeskrivning && <p className="text-[14px] text-[#333333]">{rad.felbeskrivning}</p>}
<p className="mt-1 text-[12px] text-[#707070]">
Ansvarig: <span className="font-semibold text-[#333333]">{rad.ansvarig ?? rad.skapare ?? "—"}</span>
@@ -16,10 +16,10 @@ interface DelatSvar {
}
const NIVA_NOTIS: Record<string, string> = {
kund: "Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras.",
kund: "Read-only live view from the workshop — the page updates automatically as new information is recorded.",
partner:
"Skrivskyddad livevy (partnernivå) — inkluderar tekniska hypoteser, tydligt märkta som ej verifierade. Uppdateras automatiskt.",
intern: "Skrivskyddad livevy (intern nivå) — full insyn i ärendet. Uppdateras automatiskt.",
"Read-only live view (partner level) — includes technical hypotheses, clearly marked as unverified. Updates automatically.",
intern: "Read-only live view (internal level) — full visibility of the case. Updates automatically.",
};
async function hamtaDelat(kod: string): Promise<{ arende: Arende; niva: string } | undefined> {
@@ -87,12 +87,12 @@ export default function PublikDelning() {
if (status !== "klar" || !arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<FelsokningSkal rubrik="Shared case">
<Panel>
<p className="text-[14px] text-[#333333]">
{status === "laddar"
? "Hämtar ärendet …"
: "Ärendet är inte tillgängligt. Kontrollera länken med verkstaden — delningen kan ha stängts av."}
? "Retrieving the case …"
: "The case is not available. Check the link with the workshop — the share may have been revoked."}
</p>
</Panel>
</FelsokningSkal>
@@ -105,7 +105,7 @@ export default function PublikDelning() {
niva === "kund" && kod
? async (beslut: "godkant" | "avbojt", kommentar: string) => {
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
if (!plattformAktiv()) return "Beskedet kan inte lämnas härkontakta verkstaden.";
if (!plattformAktiv()) return "The decision cannot be given herecontact the workshop.";
try {
const res = await fetch(`${PLATTFORM_URL}/api/delad/${kod}/beslut`, {
method: "POST",
@@ -114,14 +114,14 @@ export default function PublikDelning() {
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
return data.error ?? "Beskedet kunde inte registreras — försök igen.";
return data.error ?? "The decision could not be recorded — try again.";
}
// Hämta om direkt så kvittensen syns.
const uppdaterat = await hamtaDelat(kod);
if (uppdaterat) setArende(uppdaterat.arende);
return null;
} catch {
return "Beskedet kunde inte skickas — kontrollera anslutningen.";
return "The decision could not be sent — check the connection.";
}
}
: undefined;