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:
@@ -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>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user