Industriell ETKA-inspirerad design + Personbil/Regnr

Hela felsökningsmodulen omdesignad enligt tysk verkstadstradition
(ETKA/Bosch/Siemens-känsla): plana ljusgrå ytor, skarpa kanter, djup
marinblå primärfärg i stället för amber, tät typografi 11–15 px,
rektangulära knappar (max 4 px radie) och låg verktygsrad (~44 px)
med breadcrumb.

Ärendesidan får klassisk trekolumnslayout på skrivbord:
- Vänster: navigationsträd med ärendevyer + metodikstegens status
- Mitten: arbetsytan (guiden, en fråga i taget)
- Höger: kontextpanel med teknisk information, tillförlitlighet,
  rekommenderat nästa steg och senaste tekniska rekommendation (AI)
Smala skärmar behåller flikrad och en kolumn.

Dessutom: objekttypen "Fordon" heter nu "Personbil" och
"Registreringsnummer" har kortats till "Regnr".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 08:43:57 +00:00
parent 63b7720159
commit c3e831b478
16 changed files with 386 additions and 220 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
- **Händelseloggen är enda sanningskällan.** `src/felsokning/domain.ts` definierar händelsetyperna; poster läggs endast till.
- **Alla vyer är projektioner.** `src/felsokning/projektioner.ts` — brief, tidsfördelning, överlämningstext och kundrapport är rena funktioner av loggen och kan alltid regenereras. Testerna i `src/felsokning/__tests__/` låser detta.
- **Metodikmotorn är deterministisk.** `src/felsokning/metodik.ts` — nästa steg härleds ur vad som redan dokumenterats. Det är här den framtida AI:n ansluter, utan att logg eller projektioner ändras.
- **Fabriksverktygs-UI.** `src/felsokning/ui.tsx` — hög kontrast, minst 56 px höga knappar, få val per skärm.
- **Industriellt verkstads-UI (ETKA-inspirerat).** `src/felsokning/ui.tsx` — plana ljusgrå ytor (#ECECEC/#F7F7F7), skarpa kanter, djup marinblå som primärfärg, tät typografi (1115 px), rektangulära knappar (max 4 px radie), verktygsrad ~44 px. Ärendesidan har klassisk trekolumnslayout på skrivbord: navigationsträd (vyer + metodikstegens status) till vänster, arbetsyta i mitten, kontextpanel (teknisk information, tillförlitlighet, teknisk rekommendation) till höger; en kolumn med flikrad på smala skärmar.
## Medvetna avgränsningar
+19 -19
View File
@@ -40,31 +40,31 @@ export function DelatArendeVy({
rubrik={b.objekt?.beskrivning ?? "Ärende"}
hoger={
<span
className={`rounded px-3 py-1 text-sm font-extrabold uppercase ${
avslutat ? "bg-zinc-700 text-zinc-200" : "bg-green-500 text-zinc-950"
className={`rounded px-3 py-1 text-[12px] font-semibold uppercase ${
avslutat ? "bg-[#3E5A78] text-white" : "bg-[#1E6B34] text-white"
}`}
>
{avslutat ? "Avslutat" : "🟢 Felsökning pågår"}
</span>
}
>
<p className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-3 text-sm text-zinc-400 print:hidden">
<p className="mb-4 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#4A5560] print:hidden">
{notis}
</p>
{b.felbeskrivning && (
<Panel rubrik="Kundens felbeskrivning">
<p className="text-lg">{b.felbeskrivning}</p>
<p className="text-[14px]">{b.felbeskrivning}</p>
</Panel>
)}
<Panel rubrik="Aktuell status">
{b.utfordaKontroller.map((k, i) => (
<p key={`u${i}`} className="py-0.5 text-lg"> {k.text}</p>
<p key={`u${i}`} className="py-0.5 text-[14px]"> {k.text}</p>
))}
{!avslutat && pagaende && <p className="py-0.5 text-lg text-amber-400">🔄 {pagaende}</p>}
{!avslutat && pagaende && <p className="py-0.5 text-[14px] text-[#00437A]">🔄 {pagaende}</p>}
{b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => (
<p key={`e${i}`} className="py-0.5 text-lg text-zinc-400"> {e}</p>
<p key={`e${i}`} className="py-0.5 text-[14px] text-[#4A5560]"> {e}</p>
))}
</Panel>
@@ -73,8 +73,8 @@ export function DelatArendeVy({
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-zinc-700" />
<figcaption className="mt-1 text-xs text-zinc-400">{bild.beskrivning}</figcaption>
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
</figure>
))}
</div>
@@ -83,15 +83,15 @@ export function DelatArendeVy({
{matvarden.length > 0 && (
<Panel rubrik="Mätvärden">
<table className="w-full text-lg">
<table className="w-full text-[14px]">
<tbody>
{matvarden.map((p) => {
const h = p.handelse;
if (h.typ !== "matvarde") return null;
return (
<tr key={p.id} className="border-b border-zinc-800 last:border-0">
<td className="py-1 pr-3 text-zinc-300">{h.beskrivning}</td>
<td className="py-1 font-bold">
<tr key={p.id} className="border-b border-[#DDDDDD] last:border-0">
<td className="py-1 pr-3 text-[#333333]">{h.beskrivning}</td>
<td className="py-1 font-semibold">
{h.varde}
{h.enhet ? ` ${h.enhet}` : ""}
</td>
@@ -105,8 +105,8 @@ export function DelatArendeVy({
<Panel rubrik="Tidslinje">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-base">
<span className="font-mono font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>{" "}
<p key={post.id} className="py-0.5 text-[13px]">
<span className="font-mono font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>{" "}
{handelseRubrik(post)}
</p>
))}
@@ -114,20 +114,20 @@ export function DelatArendeVy({
{!avslutat && pagaende && (
<Panel rubrik="Rekommenderat nästa steg">
<p className="text-lg">{pagaende}</p>
<p className="text-[14px]">{pagaende}</p>
</Panel>
)}
<Panel rubrik="Arbetstid">
<p className="text-2xl font-extrabold">{b.totalArbetstid}</p>
<p className="text-[17px] font-semibold">{b.totalArbetstid}</p>
{tidsfordelningsRader(arende, nu).map((r) => (
<p key={r.label} className="text-zinc-300">
<p key={r.label} className="text-[#333333]">
{r.label}: {r.tid}
</p>
))}
</Panel>
<p className="text-center text-xs text-zinc-600">
<p className="text-center text-[11px] text-[#8A8A8A]">
Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg
</p>
</FelsokningSkal>
+4 -4
View File
@@ -47,16 +47,16 @@ export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) {
type="button"
onClick={() => (lyssnar ? stoppa() : starta())}
aria-label={lyssnar ? "Stoppa inspelning" : "Starta röstinmatning"}
className={`min-h-11 min-w-11 rounded-full border-2 px-3 text-lg font-bold transition-colors ${
className={`min-h-8 min-w-11 rounded border px-3 text-[14px] font-semibold transition-colors ${
lyssnar
? "animate-pulse border-red-500 bg-red-600 text-white"
: "border-zinc-600 bg-zinc-800 text-zinc-100 hover:border-amber-400"
? "animate-pulse border-[#6E1414] bg-[#8B1A1A] text-white"
: "border-[#ADADAD] bg-white text-[#1A1A1A] hover:border-[#00437A]"
}`}
>
{lyssnar ? "■" : "🎤"}
</button>
{lyssnar && (
<span className="text-sm font-bold text-red-400">
<span className="text-[12px] font-semibold text-[#8B1A1A]">
Inspelning pågår{interim ? ` — ”${interim}` : "…"}
</span>
)}
@@ -17,10 +17,10 @@ describe("organisationsinställningar", () => {
it("okända värden filtreras bort och ordningen följer standardlistan", () => {
const inst = normalisera({
objekttyper: ["Hydraulik", "Påhittad typ", "Fordon"],
objekttyper: ["Hydraulik", "Påhittad typ", "Personbil"],
identifieringsmetoder: ["VIN"],
});
expect(inst.objekttyper).toEqual(["Fordon", "Hydraulik"]);
expect(inst.objekttyper).toEqual(["Personbil", "Hydraulik"]);
expect(inst.identifieringsmetoder).toEqual(["VIN"]);
});
@@ -18,7 +18,7 @@ function byggArende(poster: [string, Handelse][]): Arende {
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: {
typ: "Fordon",
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Registreringsnummer",
beskrivning: "Volvo XC60 D4 2019",
+2 -2
View File
@@ -57,9 +57,9 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
post(0, {
typ: "objekt_identifierat",
objekt: {
typ: "Fordon",
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Registreringsnummer",
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
},
+1 -1
View File
@@ -32,7 +32,7 @@ export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
export interface Objekt {
typ: string; // t.ex. "Fordon", "Industrimaskin"
identifierare: string; // reg.nr, VIN, serienummer …
identifieringsmetod: string; // "Registreringsnummer", "VIN", "Serienummer", "Manuell inmatning"
identifieringsmetod: string; // "Regnr", "VIN", "Serienummer", "Manuell inmatning"
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
kund?: string;
}
+2 -2
View File
@@ -7,7 +7,7 @@
import { plattformAktiv, plattformFetch, plattformToken } from "./plattform";
export const ALLA_OBJEKTTYPER = [
"Fordon",
"Personbil",
"Lastbil/Buss",
"Entreprenadmaskin",
"Industrimaskin",
@@ -17,7 +17,7 @@ export const ALLA_OBJEKTTYPER = [
];
export const ALLA_IDENTIFIERINGSMETODER = [
"Registreringsnummer",
"Regnr",
"VIN",
"Serienummer",
"Maskinnummer",
+50 -22
View File
@@ -1,6 +1,9 @@
// Delade UI-komponenter för Guidad Felsökning.
// Designfilosofi: professionellt fabriksverktyg — hög kontrast, stora
// träffytor (handskvänligt), få val per skärm, inga distraktioner.
// Designfilosofi: industriellt verkstadsverktyg i ETKA-tradition —
// plana ytor, skarpa kanter, tät informationslayout, dämpad palett
// (ljusgrå ytor, djup marinblå som primärfärg), inga dekorationer.
// Verktygsrad ~44 px, rektangulära knappar (max 4 px radie), kompakta
// formulär med vänsterställda etiketter.
import { Link } from "react-router-dom";
import { useRef, type ReactNode } from "react";
@@ -8,33 +11,54 @@ import type { Tillforlitlighet } from "./domain";
import { TILLFORLITLIGHET_LABEL } from "./domain";
import { MikrofonKnapp } from "./Mikrofon";
// Industriell palett (inspirerad av tyska verkstadssystem):
// bakgrund #ECECEC · paneler #F7F7F7 · kanter #C6C6C6
// primär (djup blå) #00437A · markerad rad #D6E4F2
// varning #9A6700 · fel #8B1A1A · ok #1E6B34
const TYPSNITT = "'Inter', 'IBM Plex Sans', 'Segoe UI', Roboto, system-ui, sans-serif";
export function FelsokningSkal({
rubrik,
tillbaka,
hoger,
bred,
children,
}: {
rubrik: string;
tillbaka?: { till: string; text: string };
hoger?: ReactNode;
bred?: boolean;
children: ReactNode;
}) {
const maxBredd = bred ? "max-w-[1240px]" : "max-w-3xl";
return (
<div className="felsokning-print min-h-screen bg-zinc-950 text-zinc-50">
<header className="sticky top-0 z-20 border-b-2 border-zinc-700 bg-zinc-900 px-4 py-3 print:static">
<div className="mx-auto flex max-w-3xl items-center justify-between gap-3">
<div className="min-w-0">
<div
className="felsokning-print min-h-screen bg-[#ECECEC] text-[13px] text-[#1A1A1A]"
style={{ fontFamily: TYPSNITT }}
>
{/* Verktygsrad: låg, mörk marinblå — breadcrumb till vänster,
status till höger. Inget stort sidhuvud. */}
<header className="sticky top-0 z-20 border-b border-[#0A2A4A] bg-[#123A63] px-3 text-white print:static">
<div className={`mx-auto flex min-h-11 ${maxBredd} items-center justify-between gap-3 py-1`}>
<div className="flex min-w-0 items-center gap-2">
{tillbaka && (
<Link to={tillbaka.till} className="text-sm font-semibold text-amber-400 hover:underline">
{tillbaka.text}
</Link>
<>
<Link
to={tillbaka.till}
className="whitespace-nowrap text-[12px] font-medium text-[#A9C3DE] hover:text-white hover:underline"
>
{tillbaka.text}
</Link>
<span className="text-[#5B7CA0]">/</span>
</>
)}
<h1 className="truncate text-xl font-extrabold tracking-tight">{rubrik}</h1>
<h1 className="truncate text-[14px] font-semibold tracking-tight">{rubrik}</h1>
</div>
{hoger}
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-5 pb-24">{children}</main>
<main className={`mx-auto ${maxBredd} px-3 py-4 pb-20`}>{children}</main>
</div>
);
}
@@ -56,16 +80,16 @@ export function StorKnapp({
}) {
const farg =
variant === "primar"
? "bg-amber-400 text-zinc-950 hover:bg-amber-300 active:bg-amber-500"
? "border border-[#003561] bg-[#00437A] text-white hover:bg-[#005A9E] active:bg-[#003561]"
: variant === "fara"
? "bg-red-600 text-white hover:bg-red-500 active:bg-red-700"
: "bg-zinc-800 text-zinc-50 border-2 border-zinc-600 hover:bg-zinc-700 active:bg-zinc-600";
? "border border-[#6E1414] bg-[#8B1A1A] text-white hover:bg-[#A32020] active:bg-[#6E1414]"
: "border border-[#ADADAD] bg-white text-[#1A1A1A] hover:bg-[#EDF2F7] hover:border-[#00437A] active:bg-[#D6E4F2]";
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`min-h-14 w-full rounded-lg px-5 py-3 text-lg font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 print:hidden ${farg} ${className}`}
className={`min-h-10 w-full rounded px-3 py-1.5 text-[13px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 print:hidden ${farg} ${className}`}
>
{children}
</button>
@@ -74,15 +98,19 @@ export function StorKnapp({
export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNode }) {
return (
<section className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4">
{rubrik && <h2 className="mb-3 text-sm font-extrabold uppercase tracking-widest text-zinc-400">{rubrik}</h2>}
<section className="mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-3 shadow-[0_1px_2px_rgba(0,0,0,0.06)]">
{rubrik && (
<h2 className="mb-2 border-b border-[#DDDDDD] pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-[#4A5560]">
{rubrik}
</h2>
)}
{children}
</section>
);
}
export function NivaBadge({ niva }: { niva: Tillforlitlighet }) {
return <span className="whitespace-nowrap text-sm font-bold">{TILLFORLITLIGHET_LABEL[niva]}</span>;
return <span className="whitespace-nowrap text-[12px] font-semibold">{TILLFORLITLIGHET_LABEL[niva]}</span>;
}
export function TextFalt({
@@ -107,11 +135,11 @@ export function TextFalt({
const vardeRef = useRef(varde);
vardeRef.current = varde;
const klass =
"w-full rounded-lg border-2 border-zinc-600 bg-zinc-950 px-4 py-3 text-lg text-zinc-50 placeholder-zinc-500 focus:border-amber-400 focus:outline-none";
"w-full rounded border border-[#ADADAD] bg-white px-2.5 py-1.5 text-[13px] text-[#1A1A1A] placeholder-[#9AA0A6] focus:border-[#00437A] focus:outline-none focus:ring-1 focus:ring-[#00437A]";
return (
<label className="mb-3 block">
<span className="mb-1 flex min-h-11 items-center justify-between gap-2">
<span className="text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
<label className="mb-2.5 block">
<span className="mb-1 flex min-h-6 items-center justify-between gap-2">
<span className="text-left text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">{label}</span>
{rost && (
<MikrofonKnapp
paText={(text) => satt(vardeRef.current ? `${vardeRef.current.trimEnd()} ${text}` : text)}
+234 -96
View File
@@ -94,7 +94,7 @@ export default function ArendeSida() {
if (!arende || !id) {
return (
<FelsokningSkal rubrik="Ärendet hittades inte" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<p className="text-lg text-zinc-300">Ärendet finns inte den här enheten.</p>
<p className="text-[14px] text-[#333333]">Ärendet finns inte den här enheten.</p>
</FelsokningSkal>
);
}
@@ -106,24 +106,28 @@ export default function ArendeSida() {
return (
<FelsokningSkal
bred
rubrik={`#${arende.nummer} ${o?.beskrivning ?? ""}`}
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
hoger={
<div className="text-right">
<p className="text-lg font-extrabold text-amber-400">{total}</p>
<p className="text-xs font-bold uppercase text-zinc-500">
<p className="text-[13px] font-semibold text-white">{total}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#A9C3DE]">
{avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]}
</p>
</div>
}
>
<nav className="mb-4 grid grid-cols-4 gap-1 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-1">
{/* Klassisk trekolumnslayout på skrivbord: navigationsträd till
vänster, arbetsyta i mitten, kontextpanel till höger. På smala
skärmar: flikrad + en kolumn. */}
<nav className="mb-3 grid grid-cols-4 gap-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-1 lg:hidden">
{FLIKAR.map((f) => (
<button
key={f.id}
onClick={() => setFlik(f.id)}
className={`min-h-12 rounded-md text-base font-extrabold transition-colors ${
flik === f.id ? "bg-amber-400 text-zinc-950" : "text-zinc-300 hover:bg-zinc-800"
className={`min-h-9 rounded text-[13px] font-semibold transition-colors ${
flik === f.id ? "bg-[#00437A] text-white" : "text-[#333333] hover:bg-[#E4E9EE]"
}`}
>
{f.label}
@@ -131,18 +135,152 @@ export default function ArendeSida() {
))}
</nav>
{!avslutat && <InaktivitetsBanner arende={arende} nu={nu} skicka={skicka} />}
<div className="lg:grid lg:grid-cols-[210px_minmax(0,1fr)] lg:items-start lg:gap-3 xl:grid-cols-[210px_minmax(0,1fr)_300px]">
<aside className="sticky top-14 hidden lg:block print:hidden">
<VyTrad flik={flik} sattFlik={setFlik} arende={arende} metodik={metodik} />
</aside>
{flik === "guide" && (
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
)}
{flik === "logg" && <LoggFlik arende={arende} />}
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
<div className="min-w-0">
{!avslutat && <InaktivitetsBanner arende={arende} nu={nu} skicka={skicka} />}
{flik === "guide" && (
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
)}
{flik === "logg" && <LoggFlik arende={arende} />}
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
</div>
<aside className="sticky top-14 hidden xl:block print:hidden">
<KontextPanel arende={arende} metodik={metodik} nu={nu} synkStatus={synkStatus} />
</aside>
</div>
</FelsokningSkal>
);
}
// Navigationsträd (vänsterkolumnen): ärendets vyer plus metodikens steg
// med status — som en mappstruktur i ett klassiskt verkstadssystem.
function VyTrad({
flik,
sattFlik,
arende,
metodik,
}: {
flik: Flik;
sattFlik: (f: Flik) => void;
arende: Arende;
metodik: Metodik;
}) {
const steg = nastaSteg(arende, metodik);
const aktuellIndex = metodik.steg.findIndex((s) => s.id === steg.steg.id);
return (
<nav className="overflow-hidden rounded border border-[#C6C6C6] bg-[#F7F7F7] shadow-[0_1px_2px_rgba(0,0,0,0.06)]">
<p className="border-b border-[#DDDDDD] bg-[#EFEFEF] px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-[#4A5560]">
Ärendevyer
</p>
{FLIKAR.map((f) => (
<button
key={f.id}
onClick={() => sattFlik(f.id)}
className={`block w-full border-l-2 px-3 py-1.5 text-left text-[13px] transition-colors ${
flik === f.id
? "border-[#00437A] bg-[#D6E4F2] font-semibold text-[#00437A]"
: "border-transparent text-[#333333] hover:bg-[#E4E9EE]"
}`}
>
{f.label}
</button>
))}
<p className="border-y border-[#DDDDDD] bg-[#EFEFEF] px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-[#4A5560]">
{metodik.namn}
</p>
{metodik.steg.map((s, i) => {
const klar = steg.klart || i < aktuellIndex;
const aktuell = !steg.klart && i === aktuellIndex;
return (
<p
key={s.id}
className={`px-3 py-1 text-[12px] ${
aktuell ? "bg-[#FFF8E1] font-semibold text-[#9A6700]" : klar ? "text-[#1E6B34]" : "text-[#707070]"
}`}
>
{klar ? "✓" : aktuell ? "▸" : "·"} {s.rubrik}
</p>
);
})}
</nav>
);
}
// Kontextpanel (högerkolumnen): teknisk information, tillförlitlighet och
// senaste tekniska rekommendation — alltid synlig på breda skärmar.
function KontextPanel({
arende,
metodik,
nu,
synkStatus,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
synkStatus: SynkStatus;
}) {
const b = brief(arende, metodik, nu);
const senasteAi = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
const rad = (etikett: string, varde: string | undefined) =>
varde ? (
<div className="flex justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[12px] last:border-0">
<span className="shrink-0 text-[#4A5560]">{etikett}</span>
<span className="text-right font-medium text-[#1A1A1A]">{varde}</span>
</div>
) : null;
return (
<div>
<Panel rubrik="Teknisk information">
{rad("Objekt", b.objekt?.beskrivning)}
{rad("Ident", b.objekt?.identifierare)}
{rad("Kund", b.objekt?.kund)}
{rad("Ansvarig", b.ansvarig)}
{rad("Status", b.avslutat ? "Avslutat" : "Pågår")}
{rad("Synk", SYNKSTATUS_LABEL[synkStatus])}
{rad("Arbetstid", b.totalArbetstid)}
</Panel>
<Panel rubrik="Tillförlitlighet">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-[12px]">
<NivaBadge niva={r.niva} /> {r.text}
</p>
))}
</Panel>
{!b.avslutat && b.rekommenderatNastaSteg.length > 0 && (
<Panel rubrik="Rekommenderat nästa steg">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-[12px] text-[#333333]">
{i + 1}. {s}
</p>
))}
</Panel>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && (
<Panel rubrik="Teknisk rekommendation">
{senasteAi.handelse.rader.map((r, i) => (
<p key={i} className="py-0.5 text-[12px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[r.typ]}:</span> {r.text}
</p>
))}
<p className="mt-1 text-[12px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-[#707070]">
AI-underlag · presenteras aldrig som konstaterat fel
</p>
</Panel>
)}
</div>
);
}
// Vid längre inaktivitet ber systemet om sammanhang — svaret blir en loggpost.
function InaktivitetsBanner({
arende,
@@ -159,11 +297,11 @@ function InaktivitetsBanner({
const minuter = Math.floor((new Date(nu).getTime() - new Date(sista.tidpunkt).getTime()) / 60000);
if (minuter < 20) return null;
return (
<div className="mb-4 rounded-lg border-2 border-amber-400 bg-zinc-900 p-4">
<p className="mb-2 text-lg font-bold text-amber-400">
<div className="mb-4 rounded border border-[#00437A] bg-[#F7F7F7] p-4">
<p className="mb-2 text-[14px] font-semibold text-[#00437A]">
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
</p>
<p className="mb-3 text-zinc-300">Beskriv kort vad som gjorts under denna period.</p>
<p className="mb-3 text-[#333333]">Beskriv kort vad som gjorts under denna period.</p>
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." rost />
<StorKnapp
disabled={!text.trim()}
@@ -216,7 +354,7 @@ function GuideFlik({
if (avslutat) {
return (
<Panel rubrik="Felsökningen är avslutad">
<p className="text-lg text-zinc-300">
<p className="text-[14px] text-[#333333]">
Ärendet är låst för nya guidesteg. Loggen, briefen och rapporten finns kvar för spårbarhet och export.
</p>
</Panel>
@@ -229,8 +367,8 @@ function GuideFlik({
<Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}>
{steg.klart ? (
<>
<p className="mb-3 text-xl font-bold">Samtliga steg i metodiken är dokumenterade.</p>
<p className="mb-4 text-zinc-300">
<p className="mb-3 text-[15px] font-semibold">Samtliga steg i metodiken är dokumenterade.</p>
<p className="mb-4 text-[#333333]">
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
med rekommenderade nästa steg.
</p>
@@ -253,19 +391,19 @@ function GuideFlik({
: "AI-handledning"
}
>
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar </p>}
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">AI analyserar </p>}
{aiStatus === "fel" && (
<p className="mb-2 font-bold text-red-400">AI-svaret kunde inte hämtas försök igen. Metodiken fortsätter som vanligt.</p>
<p className="mb-2 font-semibold text-[#8B1A1A]">AI-svaret kunde inte hämtas försök igen. Metodiken fortsätter som vanligt.</p>
)}
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
<>
{senasteAiSvar.handelse.rader.map((rad, i) => (
<p key={i} className="py-0.5 text-lg">
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
<p key={i} className="py-0.5 text-[14px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
<p className="mt-2 text-lg">
<span className="font-extrabold text-amber-400">Nästa steg:</span>{" "}
<p className="mt-2 text-[14px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span>{" "}
{senasteAiSvar.handelse.nastaSteg}
</p>
</>
@@ -302,10 +440,10 @@ function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse)
<button
key={k}
onClick={() => k !== aktiv && skicka({ typ: "kategori_byte", kategori: k })}
className={`whitespace-nowrap rounded-full border-2 px-4 py-2 text-sm font-extrabold transition-colors ${
className={`whitespace-nowrap rounded border px-4 py-2 text-[12px] font-semibold transition-colors ${
k === aktiv
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-300 hover:border-zinc-400"
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#333333] hover:border-[#8FA8C0]"
}`}
>
{TIDKATEGORI_LABEL[k]}
@@ -324,7 +462,7 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
return (
<>
<p className="mb-4 text-2xl font-extrabold leading-snug">{fraga.text}</p>
<p className="mb-4 text-[17px] font-semibold leading-snug">{fraga.text}</p>
{fraga.svarstyp === "janej" && (
<div className="grid grid-cols-2 gap-2">
<StorKnapp onClick={() => svara("Ja")}>Ja</StorKnapp>
@@ -374,8 +512,8 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
return (
<>
{steg.steg.beskrivning && <p className="mb-2 text-zinc-400">{steg.steg.beskrivning}</p>}
<p className="mb-4 text-2xl font-extrabold leading-snug">{kontroll.text}</p>
{steg.steg.beskrivning && <p className="mb-2 text-[#4A5560]">{steg.steg.beskrivning}</p>}
<p className="mb-4 text-[17px] font-semibold leading-snug">{kontroll.text}</p>
{krav === "foto" ? (
<>
<input
@@ -394,7 +532,7 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
/>
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
<StorKnapp onClick={() => filRef.current?.click()}>📷 Ta foto verifierar kontrollen</StorKnapp>
<p className="mt-2 text-sm text-zinc-500">Denna kontroll verifieras med foto.</p>
<p className="mt-2 text-[12px] text-[#707070]">Denna kontroll verifieras med foto.</p>
</>
) : (
<>
@@ -412,7 +550,7 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
rost
/>
{!kravUppfyllt && (
<p className="mb-3 text-sm font-bold text-amber-400">
<p className="mb-3 text-[12px] font-semibold text-[#00437A]">
Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats lägg till en kort{" "}
{krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.
</p>
@@ -484,10 +622,10 @@ function SnabbDokumentation({
<button
key={d.id}
onClick={() => (d.id === "foto" ? filRef.current?.click() : setTyp(typ === d.id ? null : d.id))}
className={`min-h-12 rounded-lg border-2 text-sm font-extrabold transition-colors ${
className={`min-h-9 rounded border text-[12px] font-semibold transition-colors ${
typ === d.id
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-200 hover:border-zinc-400"
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#333333] hover:border-[#8FA8C0]"
}`}
>
+ {d.label}
@@ -511,7 +649,7 @@ function SnabbDokumentation({
{typ && typ !== "foto" && (
<div className="mt-3">
{typ === "hypotes" && (
<p className="mb-2 text-sm font-bold text-red-400">
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
🔴 En hypotes är en möjlig felorsak som kräver verifiering den loggas aldrig som ett konstaterat fel.
</p>
)}
@@ -571,9 +709,9 @@ function OverlamningDialog({
return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div className="max-h-full w-full max-w-2xl overflow-y-auto rounded-lg border-2 border-zinc-600 bg-zinc-900 p-4">
<h2 className="mb-3 text-xl font-extrabold">Överlämningsrapport</h2>
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg bg-zinc-950 p-4 text-sm text-zinc-200">
<div className="max-h-full w-full max-w-2xl overflow-y-auto rounded border border-[#ADADAD] bg-[#F7F7F7] p-4">
<h2 className="mb-3 text-[15px] font-semibold">Överlämningsrapport</h2>
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded bg-white p-4 text-[12px] text-[#333333]">
{text}
</pre>
{aiLage === "vilar" && (
@@ -581,15 +719,15 @@ function OverlamningDialog({
AI-komplettera: risker &amp; osäkerheter
</StorKnapp>
)}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-bold text-amber-400">AI sammanfattar </p>}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">AI sammanfattar </p>}
{aiLage === "fel" && (
<p className="mb-3 font-bold text-red-400">Kunde inte hämtas kräver inloggning. Överlämningen fungerar ändå.</p>
<p className="mb-3 font-semibold text-[#8B1A1A]">Kunde inte hämtas kräver inloggning. Överlämningen fungerar ändå.</p>
)}
{aiLage === "klar" && aiRader.length > 0 && (
<div className="mb-3 rounded-lg bg-zinc-950 p-4">
<div className="mb-3 rounded bg-white p-4">
{aiRader.map((rad, i) => (
<p key={i} className="py-0.5 text-sm text-zinc-200">
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
<p key={i} className="py-0.5 text-[12px] text-[#333333]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
</div>
@@ -652,18 +790,18 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
}
return (
<div className="mt-3 rounded-lg border-2 border-zinc-700 bg-zinc-950 p-3">
{fel && <p className="mb-2 font-bold text-red-400">{fel}</p>}
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-3">
{fel && <p className="mb-2 font-semibold text-[#8B1A1A]">{fel}</p>}
{delningar.map((delning) => (
<div key={delning.kod} className="flex items-center justify-between gap-2 border-b border-zinc-800 py-2 last:border-0">
<span className="text-lg">
<div key={delning.kod} className="flex items-center justify-between gap-2 border-b border-[#DDDDDD] py-2 last:border-0">
<span className="text-[14px]">
{NIVA_LABEL[delning.niva]}{" "}
{delning.aterkallad && <span className="text-sm font-bold text-red-400">(återkallad)</span>}
{delning.aterkallad && <span className="text-[12px] font-semibold text-[#8B1A1A]">(återkallad)</span>}
</span>
{!delning.aterkallad && (
<span className="flex gap-2">
<button
className="rounded border-2 border-zinc-600 px-3 py-1 font-bold text-zinc-200 hover:border-amber-400"
className="rounded border border-[#ADADAD] px-3 py-1 font-semibold text-[#333333] hover:border-[#00437A]"
onClick={() =>
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${delning.kod}`)
}
@@ -671,7 +809,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
Kopiera
</button>
<button
className="rounded border-2 border-zinc-600 px-3 py-1 font-bold text-red-400 hover:border-red-400"
className="rounded border border-[#ADADAD] px-3 py-1 font-semibold text-[#8B1A1A] hover:border-[#8B1A1A]"
onClick={async () => {
await aterkallaDelning(delning.kod);
skicka({ typ: "kommentar", text: `Delningslänk (${NIVA_LABEL[delning.niva]}) återkallad.` });
@@ -688,7 +826,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
{(Object.keys(NIVA_LABEL) as DelningsNiva[]).map((niva) => (
<button
key={niva}
className="min-h-12 rounded-lg border-2 border-zinc-600 text-sm font-extrabold text-zinc-200 hover:border-amber-400"
className="min-h-9 rounded border border-[#ADADAD] text-[12px] font-semibold text-[#333333] hover:border-[#00437A]"
onClick={async () => {
try {
await skapaDelning(arende.id, niva);
@@ -703,7 +841,7 @@ function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Han
</button>
))}
</div>
<p className="mt-2 text-xs text-zinc-500">
<p className="mt-2 text-[11px] text-[#707070]">
Kund: det kunddelbara · Extern partner: även hypoteser (märkta ej verifierade) · Intern: full insyn.
Filtreringen sker servern.
</p>
@@ -716,13 +854,13 @@ function LoggFlik({ arende }: { arende: Arende }) {
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
<ol>
{arende.handelser.map((post) => (
<li key={post.id} className="flex gap-3 border-b border-zinc-800 py-2 last:border-0">
<span className="w-14 shrink-0 font-mono text-sm font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>
<li key={post.id} className="flex gap-3 border-b border-[#DDDDDD] py-2 last:border-0">
<span className="w-14 shrink-0 font-mono text-[12px] font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>
<div className="min-w-0">
<p className="text-base text-zinc-100">{handelseRubrik(post)}</p>
<p className="text-xs text-zinc-500">{post.anvandare}</p>
<p className="text-[13px] text-[#1A1A1A]">{handelseRubrik(post)}</p>
<p className="text-[11px] text-[#707070]">{post.anvandare}</p>
{post.handelse.typ === "foto" && (
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-zinc-700" />
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
)}
</div>
</li>
@@ -764,25 +902,25 @@ function BriefFlik({
<>
{!b.avslutat && (
<Panel rubrik="AI-granskning">
<p className="mb-2 text-zinc-300">
<p className="mb-2 text-[#333333]">
Låt AI:n igenom hela underlaget innan du lämnar det vidare: motsägelser mellan
observationer, luckor i dokumentationen och slutsatser som saknar stöd.
</p>
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI granskar underlaget </p>}
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">AI granskar underlaget </p>}
{granskning === "fel" && (
<p className="mb-2 font-bold text-red-400">Granskningen kunde inte hämtas kräver inloggning. Försök igen.</p>
<p className="mb-2 font-semibold text-[#8B1A1A]">Granskningen kunde inte hämtas kräver inloggning. Försök igen.</p>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && granskning === "vilar" && (
<div className="mb-2">
{senasteAi.handelse.rader.map((rad, i) => (
<p key={i} className="py-0.5 text-lg">
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
<p key={i} className="py-0.5 text-[14px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
<p className="mt-1 text-lg">
<span className="font-extrabold text-amber-400">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
<p className="mt-1 text-[14px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-xs text-zinc-500">Senaste AI-svar ({senasteAi.handelse.modell})</p>
<p className="mt-1 text-[11px] text-[#707070]">Senaste AI-svar ({senasteAi.handelse.modell})</p>
</div>
)}
<StorKnapp variant="sekundar" disabled={granskning === "arbetar"} onClick={granska}>
@@ -793,64 +931,64 @@ function BriefFlik({
<Panel rubrik="Objekt">
{b.objekt ? (
<>
<p className="text-xl font-extrabold">{b.objekt.beskrivning}</p>
<p className="font-bold text-amber-400">{b.objekt.identifierare}</p>
{b.objekt.kund && <p className="text-zinc-300">Kund: {b.objekt.kund}</p>}
{b.ansvarig && <p className="text-zinc-300">Ansvarig tekniker: {b.ansvarig}</p>}
<p className="text-[15px] font-semibold">{b.objekt.beskrivning}</p>
<p className="font-semibold text-[#00437A]">{b.objekt.identifierare}</p>
{b.objekt.kund && <p className="text-[#333333]">Kund: {b.objekt.kund}</p>}
{b.ansvarig && <p className="text-[#333333]">Ansvarig tekniker: {b.ansvarig}</p>}
</>
) : (
<p className="text-zinc-400">Ej identifierat</p>
<p className="text-[#4A5560]">Ej identifierat</p>
)}
</Panel>
<Panel rubrik="Kundens beskrivning">
<p className="text-lg">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
<p className="text-[14px]">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Utförda kontroller">
{b.utfordaKontroller.length === 0 && <p className="text-zinc-400">Inga ännu.</p>}
{b.utfordaKontroller.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
{b.utfordaKontroller.map((k, i) => (
<p key={i} className="py-0.5 text-lg">
<p key={i} className="py-0.5 text-[14px]">
{k.text}
{k.resultat && <span className="text-zinc-400"> {k.resultat}</span>}
{k.resultat && <span className="text-[#4A5560]"> {k.resultat}</span>}
</p>
))}
</Panel>
<Panel rubrik="Observationer">
{b.observationer.length === 0 && <p className="text-zinc-400">Inga ännu.</p>}
{b.observationer.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
{b.observationer.map((o, i) => (
<p key={i} className="py-0.5 text-lg"> {o}</p>
<p key={i} className="py-0.5 text-[14px]"> {o}</p>
))}
</Panel>
{b.hypoteser.length > 0 && (
<Panel rubrik="Hypoteser — kräver verifiering">
{b.hypoteser.map((h, i) => (
<p key={i} className="py-0.5 text-lg">
<p key={i} className="py-0.5 text-[14px]">
<NivaBadge niva={h.niva} /> {h.text}
</p>
))}
</Panel>
)}
<Panel rubrik="Ej kontrollerat">
{b.ejKontrollerat.length === 0 && <p className="text-zinc-400">Allt i metodiken är utfört.</p>}
{b.ejKontrollerat.length === 0 && <p className="text-[#4A5560]">Allt i metodiken är utfört.</p>}
{b.ejKontrollerat.map((e, i) => (
<p key={i} className="py-0.5 text-lg text-zinc-300"> {e}</p>
<p key={i} className="py-0.5 text-[14px] text-[#333333]"> {e}</p>
))}
</Panel>
{!b.avslutat && (
<Panel rubrik="Rekommenderat nästa steg">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-lg">{i + 1}. {s}</p>
<p key={i} className="py-0.5 text-[14px]">{i + 1}. {s}</p>
))}
</Panel>
)}
<Panel rubrik="Tillförlitlighet">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-lg">
<p key={i} className="py-0.5 text-[14px]">
<NivaBadge niva={r.niva} /> {r.text}
</p>
))}
</Panel>
<Panel rubrik="Total arbetstid">
<p className="text-2xl font-extrabold">{b.totalArbetstid}</p>
<p className="text-[17px] font-semibold">{b.totalArbetstid}</p>
</Panel>
</>
);
@@ -900,7 +1038,7 @@ function RapportFlik({
return (
<>
<Panel rubrik="Kundrapport">
<p className="mb-2 text-zinc-300">
<p className="mb-2 text-[#333333]">
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas bilder kan
innehålla uppgifter om andra kunder.
</p>
@@ -931,30 +1069,30 @@ function RapportFlik({
</StorKnapp>
)
)}
<p className="mt-2 text-sm text-zinc-500">
<p className="mt-2 text-[12px] text-[#707070]">
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
</p>
</Panel>
<Panel rubrik={`Ärende #${arende.nummer}`}>
{b.objekt && (
<p className="text-lg font-bold">
<p className="text-[14px] font-semibold">
{b.objekt.beskrivning} · {b.objekt.identifierare}
</p>
)}
{b.felbeskrivning && <p className="mt-1 text-lg">Felbeskrivning: {b.felbeskrivning}</p>}
<p className="mt-1 text-lg">Total arbetstid: <span className="font-extrabold">{b.totalArbetstid}</span></p>
{b.felbeskrivning && <p className="mt-1 text-[14px]">Felbeskrivning: {b.felbeskrivning}</p>}
<p className="mt-1 text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && (
<div className="mt-2">
{fordelning.map((r) => (
<p key={r.label} className="text-zinc-300">{r.label}: {r.tid}</p>
<p key={r.label} className="text-[#333333]">{r.label}: {r.tid}</p>
))}
</div>
)}
</Panel>
<Panel rubrik="Tidslinje">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-base">
<span className="font-mono font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>{" "}
<p key={post.id} className="py-0.5 text-[13px]">
<span className="font-mono font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>{" "}
{handelseRubrik(post)}
</p>
))}
@@ -964,8 +1102,8 @@ function RapportFlik({
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-zinc-700" />
<figcaption className="mt-1 text-xs text-zinc-400">{bild.beskrivning}</figcaption>
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
</figure>
))}
</div>
@@ -974,11 +1112,11 @@ function RapportFlik({
{!b.avslutat || b.ejKontrollerat.length > 0 ? (
<Panel rubrik="Rekommenderade nästa steg">
{(b.avslutat ? b.ejKontrollerat : b.rekommenderatNastaSteg).map((s, i) => (
<p key={i} className="py-0.5 text-lg">{i + 1}. {s}</p>
<p key={i} className="py-0.5 text-[14px]">{i + 1}. {s}</p>
))}
</Panel>
) : null}
<p className="text-center text-xs text-zinc-600">
<p className="text-center text-[11px] text-[#8A8A8A]">
Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.
</p>
</>
+28 -28
View File
@@ -43,12 +43,12 @@ function PlattformInloggning() {
<>
<Panel rubrik="Plattformskonto">
<div className="flex items-center justify-between gap-3">
<p className="text-lg">
<span className="font-extrabold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
<p className="text-[14px]">
<span className="font-semibold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
{konto.organisation} synk och AI aktiva.
</p>
<button
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
className="whitespace-nowrap rounded border border-[#ADADAD] px-4 py-2 font-semibold text-[#333333] hover:border-[#8FA8C0]"
onClick={() => {
loggaUtPlattform();
setKonto(null);
@@ -77,7 +77,7 @@ function PlattformInloggning() {
<Panel rubrik="Plattformskonto">
{lage === "stangd" ? (
<>
<p className="mb-2 text-zinc-300">
<p className="mb-2 text-[#333333]">
Logga in för synk mellan enheter, samarbete i ärenden och AI-stöd. Utan inloggning
arbetar appen i lokalt läge. Ny organisation? Skapa konto du blir systemadministratör.
</p>
@@ -112,7 +112,7 @@ function PlattformInloggning() {
)}
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
Avbryt
@@ -172,9 +172,9 @@ function AnvandarAdmin() {
return (
<Panel rubrik="Användare">
{lista.map((anv) => (
<p key={anv.id} className="border-b border-zinc-800 py-1 text-lg last:border-0">
<span className="font-extrabold">{anv.namn}</span> · {ROLL_LABEL[anv.roll]}{" "}
<span className="text-zinc-500">{anv.epost}</span>
<p key={anv.id} className="border-b border-[#DDDDDD] py-1 text-[14px] last:border-0">
<span className="font-semibold">{anv.namn}</span> · {ROLL_LABEL[anv.roll]}{" "}
<span className="text-[#707070]">{anv.epost}</span>
</p>
))}
<form
@@ -202,17 +202,17 @@ function AnvandarAdmin() {
key={valbar}
type="button"
onClick={() => setRoll(valbar)}
className={`min-h-12 rounded-lg border-2 text-sm font-extrabold ${
className={`min-h-9 rounded border text-[12px] font-semibold ${
roll === valbar
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-200"
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#333333]"
}`}
>
{ROLL_LABEL[valbar]}
</button>
))}
</div>
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
<StorKnapp type="submit" disabled={!epost.trim() || !namn.trim() || losenord.length < 8}>
Skapa användare
</StorKnapp>
@@ -232,7 +232,7 @@ export default function Arendelista() {
return (
<FelsokningSkal rubrik="Guidad Felsökning">
<Panel rubrik="Vem arbetar?">
<p className="mb-3 text-lg text-zinc-300">
<p className="mb-3 text-[14px] text-[#333333]">
Allt arbete loggas med användare och tidpunkt. Ange ditt namn för att börja.
</p>
<form
@@ -265,20 +265,20 @@ export default function Arendelista() {
return (
<FelsokningSkal
rubrik="Guidad Felsökning"
hoger={<span className="text-sm font-bold text-zinc-400">{anvandare}</span>}
hoger={<span className="text-[12px] font-semibold text-[#A9C3DE]">{anvandare}</span>}
>
<Link to="/felsokning/nytt">
<StorKnapp className="mb-4">+ Nytt ärende</StorKnapp>
</Link>
{alla.length > 0 && (
<div className="mb-4 grid grid-cols-3 gap-1 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-1">
<div className="mb-4 grid grid-cols-3 gap-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-1">
{FILTER.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`min-h-12 rounded-md text-base font-extrabold transition-colors ${
filter === f.id ? "bg-amber-400 text-zinc-950" : "text-zinc-300 hover:bg-zinc-800"
className={`min-h-9 rounded text-[13px] font-semibold transition-colors ${
filter === f.id ? "bg-[#00437A] text-white" : "text-[#333333] hover:bg-[#E4E9EE]"
}`}
>
{f.label} · {f.antal}
@@ -289,7 +289,7 @@ export default function Arendelista() {
{alla.length === 0 && (
<Panel rubrik="Kom igång">
<p className="mb-3 text-lg text-zinc-300">
<p className="mb-3 text-[14px] text-[#333333]">
Inga ärenden ännu. Starta med att identifiera ett objekt eller utforska ett färdigt demoärende
med komplett arbetslogg, brief och kundrapport.
</p>
@@ -300,7 +300,7 @@ export default function Arendelista() {
)}
{lista.length === 0 && alla.length > 0 && (
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
<p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i det här filtret.</p>
)}
{plattformAktiv() && <PlattformInloggning />}
@@ -312,7 +312,7 @@ export default function Arendelista() {
)}
<Panel rubrik="AI-stöd">
<p className="text-zinc-300">
<p className="text-[#333333]">
När du är inloggad hjälper AI:n 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
skiljer alltid verifierat och hypotes och slår aldrig fast en felorsak som inte är
@@ -329,23 +329,23 @@ export default function Arendelista() {
const b = brief(arende, metodikForArende(arende), new Date().toISOString());
return (
<Link key={arende.id} to={`/felsokning/arende/${arende.id}`} className="block">
<div className="mb-3 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4 transition-colors hover:border-amber-400">
<div className="mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-4 transition-colors hover:border-[#00437A]">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">
<span className="text-[12px] font-semibold uppercase tracking-widest text-[#4A5560]">
Ärende #{arende.nummer}
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-extrabold uppercase ${
avslutat ? "bg-zinc-700 text-zinc-300" : "bg-amber-400 text-zinc-950"
className={`rounded px-2 py-0.5 text-[11px] font-semibold uppercase ${
avslutat ? "bg-[#8A94A0] text-white" : "bg-[#00437A] text-white"
}`}
>
{avslutat ? "Avslutat" : "Pågående"}
</span>
</div>
<p className="mt-1 text-xl font-extrabold">{o ? o.beskrivning : "Okänt objekt"}</p>
{o && <p className="text-sm font-bold text-zinc-400">{o.identifierare}{o.kund ? ` · ${o.kund}` : ""}</p>}
{fel && <p className="mt-2 text-lg text-zinc-200">{fel}</p>}
<p className="mt-2 text-sm text-zinc-500">
<p className="mt-1 text-[15px] font-semibold">{o ? o.beskrivning : "Okänt objekt"}</p>
{o && <p className="text-[12px] font-semibold text-[#4A5560]">{o.identifierare}{o.kund ? ` · ${o.kund}` : ""}</p>}
{fel && <p className="mt-2 text-[14px] text-[#333333]">{fel}</p>}
<p className="mt-2 text-[12px] text-[#707070]">
{sista ? `Senaste aktivitet ${tidDatum(sista.tidpunkt)} ${tidKlockslag(sista.tidpunkt)}` : ""}
{" · "}
{b.totalArbetstid}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function DelatArende() {
if (!arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<p className="text-lg text-zinc-300">Ärendet är inte tillgängligt.</p>
<p className="text-[14px] text-[#333333]">Ärendet är inte tillgängligt.</p>
</FelsokningSkal>
);
}
+7 -7
View File
@@ -32,10 +32,10 @@ function Vallista({
<button
key={val}
onClick={() => vidByte(aktiv ? valda.filter((v) => v !== val) : [...valda, val])}
className={`min-h-14 rounded-lg border-2 px-2 font-extrabold transition-colors ${
className={`min-h-10 rounded border px-2 font-semibold transition-colors ${
aktiv
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-400"
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#4A5560]"
}`}
>
{aktiv ? "✓ " : ""}
@@ -64,7 +64,7 @@ export default function Installningar() {
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-lg text-zinc-300">
<p className="text-[14px] text-[#333333]">
Organisationens inställningar hanteras av er systemadministratör.
</p>
</Panel>
@@ -97,7 +97,7 @@ export default function Installningar() {
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-zinc-300">
<p className="text-[#333333]">
Välj vad som visas när ett nytt ärende startas.{" "}
{inloggad
? "Valet gäller alla i organisationen."
@@ -121,8 +121,8 @@ export default function Installningar() {
/>
</Panel>
{status === "fel" && <p className="mb-3 font-bold text-red-400">{felText}</p>}
{status === "sparat" && <p className="mb-3 font-bold text-green-400"> Sparat</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>}
<StorKnapp disabled={status === "sparar"} onClick={spara}>
{status === "sparar" ? "Sparar …" : "Spara inställningar"}
</StorKnapp>
+8 -8
View File
@@ -60,7 +60,7 @@ export default function NyttArende() {
</StorKnapp>
))}
</div>
<p className="mt-3 text-sm text-zinc-500">
<p className="mt-3 text-[12px] text-[#707070]">
QR-kod, streckkod och OCR från typskylt tillkommer i senare version ange identiteten manuellt.
</p>
</Panel>
@@ -80,11 +80,11 @@ export default function NyttArende() {
return (
<FelsokningSkal rubrik="Bekräfta objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">{objektet.typ}</p>
<p className="mt-1 text-3xl font-extrabold">{objektet.beskrivning}</p>
<p className="mt-1 text-xl font-bold text-amber-400">{objektet.identifierare}</p>
<p className="mt-1 text-sm text-zinc-400">Identifierad via: {objektet.identifieringsmetod}</p>
{objektet.kund && <p className="mt-1 text-lg text-zinc-200">Kund: {objektet.kund}</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-[15px] font-semibold text-[#00437A]">{objektet.identifierare}</p>
<p className="mt-1 text-[12px] text-[#4A5560]">Identifierad via: {objektet.identifieringsmetod}</p>
{objektet.kund && <p className="mt-1 text-[14px] text-[#333333]">Kund: {objektet.kund}</p>}
</Panel>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
@@ -110,8 +110,8 @@ export default function NyttArende() {
rost
/>
{metodik && (
<p className="mb-3 text-sm font-bold text-zinc-400">
Vald metodik: <span className="text-amber-400">{metodik.namn}</span>
<p className="mb-3 text-[12px] font-semibold text-[#4A5560]">
Vald metodik: <span className="text-[#00437A]">{metodik.namn}</span>
</p>
)}
<StorKnapp
+25 -25
View File
@@ -44,13 +44,13 @@ function Omfordela({ rad, vidKlar }: { rad: OversiktsRad; vidKlar: () => void })
);
}
return (
<div className="col-span-2 rounded-lg border-2 border-zinc-700 bg-zinc-950 p-2">
<p className="mb-2 text-sm font-bold uppercase text-zinc-400">Ny ansvarig tekniker</p>
<div className="col-span-2 rounded border border-[#C6C6C6] bg-white p-2">
<p className="mb-2 text-[12px] font-semibold uppercase text-[#4A5560]">Ny ansvarig tekniker</p>
<div className="grid grid-cols-2 gap-2">
{personer.map((person) => (
<button
key={person.id}
className="min-h-12 rounded-lg border-2 border-zinc-600 font-extrabold text-zinc-200 hover:border-amber-400"
className="min-h-9 rounded border border-[#ADADAD] font-semibold text-[#333333] hover:border-[#00437A]"
onClick={async () => {
const post = nyLoggPost(konto?.namn ?? "arbetsledare", {
typ: "ansvarig_satt",
@@ -68,7 +68,7 @@ function Omfordela({ rad, vidKlar }: { rad: OversiktsRad; vidKlar: () => void })
</button>
))}
<button
className="min-h-12 rounded-lg border-2 border-zinc-600 font-bold text-zinc-400"
className="min-h-9 rounded border border-[#ADADAD] font-semibold text-[#4A5560]"
onClick={() => setOppen(false)}
>
Avbryt
@@ -97,7 +97,7 @@ export default function Oversikt() {
return (
<FelsokningSkal rubrik="Organisationsöversikt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-lg text-zinc-300">
<p className="text-[14px] text-[#333333]">
Översikten kräver arbetsledar- eller administratörsbehörighet plattformen.
</p>
</Panel>
@@ -149,55 +149,55 @@ export default function Oversikt() {
<FelsokningSkal
rubrik="Organisationsöversikt"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
hoger={<span className="text-sm font-bold text-zinc-400">{konto?.organisation}</span>}
hoger={<span className="text-[12px] font-semibold text-[#A9C3DE]">{konto?.organisation}</span>}
>
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
{!rader && !fel && <p className="animate-pulse text-lg text-zinc-400">Hämtar </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 && (
<>
<Panel rubrik="Statistik">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-3xl font-extrabold">{rader.length}</p>
<p className="text-sm font-bold uppercase text-zinc-400">Totalt</p>
<p className="text-[20px] font-semibold">{rader.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Totalt</p>
</div>
<div>
<p className="text-3xl font-extrabold text-amber-400">{pagaende.length}</p>
<p className="text-sm font-bold uppercase text-zinc-400">Pågående</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>
</div>
<div>
<p className="text-3xl font-extrabold">{avslutade.length}</p>
<p className="text-sm font-bold uppercase text-zinc-400">Avslutade</p>
<p className="text-[20px] font-semibold">{avslutade.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Avslutade</p>
</div>
</div>
{avslutade.length > 0 && (
<p className="mt-3 text-center text-zinc-300">
Genomsnittlig ledtid för avslutade: <span className="font-extrabold">{formateraTid(medelLedtidMs)}</span>
<p className="mt-3 text-center text-[#333333]">
Genomsnittlig ledtid för avslutade: <span className="font-semibold">{formateraTid(medelLedtidMs)}</span>
</p>
)}
</Panel>
{rader.length === 0 && <p className="text-center text-lg text-zinc-400">Inga ärenden i organisationen ännu.</p>}
{rader.length === 0 && <p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i organisationen ännu.</p>}
{rader.map((rad) => (
<div key={rad.id} className="mb-3 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4">
<div key={rad.id} className="mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">
<span className="text-[12px] font-semibold uppercase tracking-widest text-[#4A5560]">
Ärende #{rad.nummer}
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-extrabold uppercase ${
rad.avslutat ? "bg-zinc-700 text-zinc-300" : "bg-amber-400 text-zinc-950"
className={`rounded px-2 py-0.5 text-[11px] font-semibold uppercase ${
rad.avslutat ? "bg-[#8A94A0] text-white" : "bg-[#00437A] text-white"
}`}
>
{rad.avslutat ? "Avslutat" : "Pågående"}
</span>
</div>
<p className="mt-1 text-xl font-extrabold">{rad.objekt ?? "Okänt objekt"}</p>
{rad.felbeskrivning && <p className="text-lg text-zinc-200">{rad.felbeskrivning}</p>}
<p className="mt-1 text-sm text-zinc-500">
Ansvarig: <span className="font-bold text-zinc-300">{rad.ansvarig ?? rad.skapare ?? "—"}</span>
<p className="mt-1 text-[15px] font-semibold">{rad.objekt ?? "Okänt objekt"}</p>
{rad.felbeskrivning && <p className="text-[14px] text-[#333333]">{rad.felbeskrivning}</p>}
<p className="mt-1 text-[12px] text-[#707070]">
Ansvarig: <span className="font-semibold text-[#333333]">{rad.ansvarig ?? rad.skapare ?? "—"}</span>
{` · ${rad.antal_handelser} händelser`}
{rad.senaste ? ` · senast ${tidDatum(rad.senaste)} ${tidKlockslag(rad.senaste)}` : ""}
</p>
+1 -1
View File
@@ -89,7 +89,7 @@ export default function PublikDelning() {
return (
<FelsokningSkal rubrik="Delat ärende">
<Panel>
<p className="text-lg text-zinc-300">
<p className="text-[14px] text-[#333333]">
{status === "laddar"
? "Hämtar ärendet …"
: "Ärendet är inte tillgängligt. Kontrollera länken med verkstaden — delningen kan ha stängts av."}