Inställningar: admin väljer objekttyper och identifieringsmetoder

Ny inställningssida (/felsokning/installningar) där systemadmin väljer
vad som visas när ett ärende startas. På plattformen sparas valet på
organisationen (alla läser via GET /api/organisation, bara admin ändrar
via POST /api/organisation/installningar); i lokalt läge gäller valet
enheten. Nytt ärende-vyn filtrerar knapparna efter valet.

- Klientmodul med normalisering: okända värden filtreras, tomma listor
  faller tillbaka till standard (går aldrig att låsa ute allt)
- installningar-kolumn (jsonb) på organisationer, idempotent migrering
- OpenAPI-specen utökad; paritets- och enhetstester
- Integrationstest: läsning för alla, 403 för tekniker, org-bred
  effekt, 400 för tomma listor

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:33:55 +00:00
parent cda92d1ed9
commit 63b7720159
12 changed files with 395 additions and 8 deletions
+2
View File
@@ -20,6 +20,7 @@ import ArendeSida from "./pages/felsokning/ArendeSida";
import DelatArende from "./pages/felsokning/DelatArende";
import PublikDelning from "./pages/felsokning/PublikDelning";
import Oversikt from "./pages/felsokning/Oversikt";
import Installningar from "./pages/felsokning/Installningar";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
@@ -49,6 +50,7 @@ function AppContent() {
<Route path="/felsokning/dela/:id" element={<DelatArende />} />
<Route path="/felsokning/delad/:kod" element={<PublikDelning />} />
<Route path="/felsokning/oversikt" element={<Oversikt />} />
<Route path="/felsokning/installningar" element={<Installningar />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
+2
View File
@@ -71,6 +71,8 @@ describe("AI-orkestern", () => {
["/api/auth/registrera", "/api/auth/registrera"],
["/api/auth/logga-in", "/api/auth/logga-in"],
["/api/anvandare", "/api/anvandare"],
["/api/organisation", "/api/organisation"],
["/api/organisation/installningar", "/api/organisation/installningar"],
["/api/arenden", "/api/arenden"],
["/api/arenden/{arendeId}/handelser", "handelser"],
["/api/oversikt", "/api/oversikt"],
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
ALLA_IDENTIFIERINGSMETODER,
ALLA_OBJEKTTYPER,
lastaInstallningar,
normalisera,
} from "../installningar";
describe("organisationsinställningar", () => {
beforeEach(() => localStorage.clear());
it("utan sparade val visas allt", () => {
const inst = lastaInstallningar();
expect(inst.objekttyper).toEqual(ALLA_OBJEKTTYPER);
expect(inst.identifieringsmetoder).toEqual(ALLA_IDENTIFIERINGSMETODER);
});
it("okända värden filtreras bort och ordningen följer standardlistan", () => {
const inst = normalisera({
objekttyper: ["Hydraulik", "Påhittad typ", "Fordon"],
identifieringsmetoder: ["VIN"],
});
expect(inst.objekttyper).toEqual(["Fordon", "Hydraulik"]);
expect(inst.identifieringsmetoder).toEqual(["VIN"]);
});
it("en tom eller trasig lista faller tillbaka till hela standardlistan", () => {
expect(normalisera({ objekttyper: [] }).objekttyper).toEqual(ALLA_OBJEKTTYPER);
expect(normalisera({ objekttyper: "fel typ" }).objekttyper).toEqual(ALLA_OBJEKTTYPER);
expect(normalisera(null).identifieringsmetoder).toEqual(ALLA_IDENTIFIERINGSMETODER);
});
it("sparade val på enheten läses tillbaka", () => {
localStorage.setItem(
"gf-installningar",
JSON.stringify({ objekttyper: ["Elsystem"], identifieringsmetoder: ["Serienummer", "Manuell inmatning"] }),
);
const inst = lastaInstallningar();
expect(inst.objekttyper).toEqual(["Elsystem"]);
expect(inst.identifieringsmetoder).toEqual(["Serienummer", "Manuell inmatning"]);
});
});
+86
View File
@@ -0,0 +1,86 @@
// Organisationsinställningar: systemadministratören väljer vilka
// objekttyper och identifieringsmetoder som visas när ett ärende startas.
// På plattformen gäller valet hela organisationen (sparas på servern);
// i lokalt läge sparas valet på den här enheten. En cache i localStorage
// gör att vyerna kan rendera direkt och uppdateras när servern svarat.
import { plattformAktiv, plattformFetch, plattformToken } from "./plattform";
export const ALLA_OBJEKTTYPER = [
"Fordon",
"Lastbil/Buss",
"Entreprenadmaskin",
"Industrimaskin",
"Elsystem",
"Hydraulik",
"Övrigt",
];
export const ALLA_IDENTIFIERINGSMETODER = [
"Registreringsnummer",
"VIN",
"Serienummer",
"Maskinnummer",
"Manuell inmatning",
];
export interface Installningar {
objekttyper: string[];
identifieringsmetoder: string[];
}
const NYCKEL = "gf-installningar";
// Okända värden filtreras bort och en tom lista faller tillbaka till
// hela standardlistan — det går aldrig att låsa ute alla alternativ.
export function normalisera(data: unknown): Installningar {
const rad = (varden: unknown, alla: string[]): string[] => {
const valda = Array.isArray(varden) ? alla.filter((v) => varden.includes(v)) : [];
return valda.length > 0 ? valda : [...alla];
};
const d = (data ?? {}) as Partial<Installningar>;
return {
objekttyper: rad(d.objekttyper, ALLA_OBJEKTTYPER),
identifieringsmetoder: rad(d.identifieringsmetoder, ALLA_IDENTIFIERINGSMETODER),
};
}
export function lastaInstallningar(): Installningar {
try {
return normalisera(JSON.parse(localStorage.getItem(NYCKEL) ?? "{}"));
} catch {
return normalisera({});
}
}
export async function hamtaInstallningar(): Promise<Installningar> {
if (plattformAktiv() && plattformToken()) {
try {
const res = await plattformFetch("/api/organisation");
if (res.ok) {
const { installningar } = (await res.json()) as { installningar: unknown };
const inst = normalisera(installningar);
localStorage.setItem(NYCKEL, JSON.stringify(inst));
return inst;
}
} catch {
// Nätverksfel: cachen/enhetens val gäller tills servern nås igen.
}
}
return lastaInstallningar();
}
export async function sparaInstallningar(inst: Installningar): Promise<void> {
const ren = normalisera(inst);
localStorage.setItem(NYCKEL, JSON.stringify(ren));
if (plattformAktiv() && plattformToken()) {
const res = await plattformFetch("/api/organisation/installningar", {
method: "POST",
body: JSON.stringify(ren),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Fel ${res.status}`);
}
}
}
+11
View File
@@ -62,6 +62,11 @@ function PlattformInloggning() {
<StorKnapp variant="sekundar">📊 Organisationsöversikt</StorKnapp>
</Link>
)}
{konto.roll === "admin" && (
<Link to="/felsokning/installningar" className="mt-2 block">
<StorKnapp variant="sekundar"> Inställningar för organisationen</StorKnapp>
</Link>
)}
</Panel>
{konto.roll === "admin" && <AnvandarAdmin />}
</>
@@ -300,6 +305,12 @@ export default function Arendelista() {
{plattformAktiv() && <PlattformInloggning />}
{!plattformKonto() && (
<Link to="/felsokning/installningar" className="mb-4 block">
<StorKnapp variant="sekundar"> Inställningar</StorKnapp>
</Link>
)}
<Panel rubrik="AI-stöd">
<p className="text-zinc-300">
När du är inloggad hjälper AI:n dig medan du arbetar: föreslår nästa steg utifrån det du
+131
View File
@@ -0,0 +1,131 @@
// Inställningar: vilka objekttyper och identifieringsmetoder som visas
// när ett ärende startas. På plattformen får bara systemadministratören
// ändra (valet gäller hela organisationen); i lokalt läge gäller valet
// den här enheten.
import { useEffect, useState } from "react";
import {
ALLA_IDENTIFIERINGSMETODER,
ALLA_OBJEKTTYPER,
hamtaInstallningar,
lastaInstallningar,
sparaInstallningar,
type Installningar as Inst,
} from "@/felsokning/installningar";
import { plattformAktiv, plattformKonto } from "@/felsokning/plattform";
import { FelsokningSkal, Panel, StorKnapp } from "@/felsokning/ui";
function Vallista({
alla,
valda,
vidByte,
}: {
alla: string[];
valda: string[];
vidByte: (nya: string[]) => void;
}) {
return (
<div className="grid grid-cols-2 gap-2">
{alla.map((val) => {
const aktiv = valda.includes(val);
return (
<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 ${
aktiv
? "border-amber-400 bg-amber-400 text-zinc-950"
: "border-zinc-600 bg-zinc-900 text-zinc-400"
}`}
>
{aktiv ? "✓ " : ""}
{val}
</button>
);
})}
</div>
);
}
export default function Installningar() {
const konto = plattformKonto();
const inloggad = plattformAktiv() && !!konto;
const farAndra = !inloggad || konto?.roll === "admin";
const [inst, setInst] = useState<Inst>(lastaInstallningar());
const [status, setStatus] = useState<"" | "sparar" | "sparat" | "fel">("");
const [felText, setFelText] = useState("");
useEffect(() => {
hamtaInstallningar().then(setInst);
}, []);
if (!farAndra) {
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-lg text-zinc-300">
Organisationens inställningar hanteras av er systemadministratör.
</p>
</Panel>
</FelsokningSkal>
);
}
const spara = async () => {
if (inst.objekttyper.length === 0 || inst.identifieringsmetoder.length === 0) {
setStatus("fel");
setFelText("Minst ett alternativ måste vara valt i varje lista.");
return;
}
setStatus("sparar");
setFelText("");
try {
await sparaInstallningar(inst);
setStatus("sparat");
} catch (misslyckande) {
setStatus("fel");
setFelText(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara.");
}
};
const byt = (nya: Partial<Inst>) => {
setInst((f) => ({ ...f, ...nya }));
setStatus("");
};
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-zinc-300">
Välj vad som visas när ett nytt ärende startas.{" "}
{inloggad
? "Valet gäller alla i organisationen."
: "I lokalt läge gäller valet den här enheten; inloggad systemadministratör styr hela organisationen."}
</p>
</Panel>
<Panel rubrik="Objekttyper">
<Vallista
alla={ALLA_OBJEKTTYPER}
valda={inst.objekttyper}
vidByte={(objekttyper) => byt({ objekttyper })}
/>
</Panel>
<Panel rubrik="Identifieringsmetoder">
<Vallista
alla={ALLA_IDENTIFIERINGSMETODER}
valda={inst.identifieringsmetoder}
vidByte={(identifieringsmetoder) => byt({ identifieringsmetoder })}
/>
</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>}
<StorKnapp disabled={status === "sparar"} onClick={spara}>
{status === "sparar" ? "Sparar …" : "Spara inställningar"}
</StorKnapp>
</FelsokningSkal>
);
}
+17 -8
View File
@@ -1,22 +1,31 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Objekt } from "@/felsokning/domain";
import { useFelsokning } from "@/felsokning/store";
import { valjMetodik } from "@/felsokning/metodik";
import { valjMetodikMedAi } from "@/felsokning/ai";
import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
const IDENTIFIERINGSMETODER = ["Registreringsnummer", "VIN", "Serienummer", "Maskinnummer", "Manuell inmatning"];
const OBJEKTTYPER = ["Fordon", "Lastbil/Buss", "Entreprenadmaskin", "Industrimaskin", "Elsystem", "Hydraulik", "Övrigt"];
// Ingen felsökning börjar innan objektet identifierats och bekräftats.
// Vilka objekttyper och identifieringsmetoder som visas styrs av
// organisationens inställningar (systemadmin) — enhetens val i lokalt läge.
export default function NyttArende() {
const navigate = useNavigate();
const skapaArende = useFelsokning((s) => s.skapaArende);
const [inst, setInst] = useState(lastaInstallningar());
const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera");
const [metod, setMetod] = useState(IDENTIFIERINGSMETODER[0]);
const [typ, setTyp] = useState(OBJEKTTYPER[0]);
const [metod, setMetod] = useState(inst.identifieringsmetoder[0]);
const [typ, setTyp] = useState(inst.objekttyper[0]);
useEffect(() => {
hamtaInstallningar().then((farsk) => {
setInst(farsk);
setTyp((t) => (farsk.objekttyper.includes(t) ? t : farsk.objekttyper[0]));
setMetod((m) => (farsk.identifieringsmetoder.includes(m) ? m : farsk.identifieringsmetoder[0]));
});
}, []);
const [identifierare, setIdentifierare] = useState("");
const [beskrivning, setBeskrivning] = useState("");
const [kund, setKund] = useState("");
@@ -36,7 +45,7 @@ export default function NyttArende() {
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Objekttyp">
<div className="grid grid-cols-2 gap-2">
{OBJEKTTYPER.map((t) => (
{inst.objekttyper.map((t) => (
<StorKnapp key={t} variant={t === typ ? "primar" : "sekundar"} onClick={() => setTyp(t)}>
{t}
</StorKnapp>
@@ -45,7 +54,7 @@ export default function NyttArende() {
</Panel>
<Panel rubrik="Identifieringsmetod">
<div className="grid grid-cols-2 gap-2">
{IDENTIFIERINGSMETODER.map((m) => (
{inst.identifieringsmetoder.map((m) => (
<StorKnapp key={m} variant={m === metod ? "primar" : "sekundar"} onClick={() => setMetod(m)}>
{m}
</StorKnapp>