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