From 63b7720159f96eb243982af5f32f5107cf8aacc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 08:33:55 +0000 Subject: [PATCH] =?UTF-8?q?Inst=C3=A4llningar:=20admin=20v=C3=A4ljer=20obj?= =?UTF-8?q?ekttyper=20och=20identifieringsmetoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- docs/MVP.md | 1 + infra/k8s/postgres-init.sql | 4 + services/plattform/integrationstest.sh | 17 +++ services/plattform/openapi.yaml | 54 ++++++++ services/plattform/server.mjs | 28 ++++ src/App.tsx | 2 + src/felsokning/__tests__/ai.test.ts | 2 + .../__tests__/installningar.test.ts | 42 ++++++ src/felsokning/installningar.ts | 86 ++++++++++++ src/pages/felsokning/Arendelista.tsx | 11 ++ src/pages/felsokning/Installningar.tsx | 131 ++++++++++++++++++ src/pages/felsokning/NyttArende.tsx | 25 ++-- 12 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 src/felsokning/__tests__/installningar.test.ts create mode 100644 src/felsokning/installningar.ts create mode 100644 src/pages/felsokning/Installningar.tsx diff --git a/docs/MVP.md b/docs/MVP.md index 0b21da1..bd3c3a4 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -32,6 +32,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. | | Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. **Behörighetsnivåer**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. | | Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. | +| Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. | | Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | diff --git a/infra/k8s/postgres-init.sql b/infra/k8s/postgres-init.sql index 31b8df1..ff1b305 100644 --- a/infra/k8s/postgres-init.sql +++ b/infra/k8s/postgres-init.sql @@ -9,8 +9,12 @@ create extension if not exists pgcrypto; create table if not exists organisationer ( id uuid primary key default gen_random_uuid(), namn text not null, + -- Vad som visas när ett ärende startas (objekttyper, + -- identifieringsmetoder) — sätts av systemadministratören. + installningar jsonb not null default '{}'::jsonb, skapad timestamptz not null default now() ); +alter table organisationer add column if not exists installningar jsonb not null default '{}'::jsonb; create table if not exists anvandare ( id uuid primary key default gen_random_uuid(), diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 8ce6e11..2323786 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -122,6 +122,23 @@ curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bea -d '{"handelser":[{"id":"h-omf","tidpunkt":"2026-08-03T08:05:00Z","anvandare":"Lisa","handelse":{"typ":"ansvarig_satt","ansvarig":"Johan"}}]}' >/dev/null kontroll "översikten visar ny ansvarig" "$(curl -s "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_L" | falt '.arenden[0].ansvarig')" "Johan" +# 9c. Organisationsinställningar: alla läser, bara admin ändrar +INST=$(curl -s "$BAS/api/organisation" -H "Authorization: Bearer $TOKEN_J") +kontroll "inloggad läser organisationen" "$(echo "$INST" | falt .namn)" "Verkstad A" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \ + -H "Authorization: Bearer $TOKEN_J" -H 'Content-Type: application/json' \ + -d '{"objekttyper":["Fordon"],"identifieringsmetoder":["VIN"]}') +kontroll "tekniker nekas ändra inställningar" "$KOD" "403" +curl -s -X POST "$BAS/api/organisation/installningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' \ + -d '{"objekttyper":["Fordon","Hydraulik"],"identifieringsmetoder":["VIN","Manuell inmatning"]}' >/dev/null +INST=$(curl -s "$BAS/api/organisation" -H "Authorization: Bearer $TOKEN_J") +kontroll "inställningarna gäller hela organisationen" "$(echo "$INST" | falt '.installningar.objekttyper.join(",")')" "Fordon,Hydraulik" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"objekttyper":[],"identifieringsmetoder":["VIN"]}') +kontroll "tomma listor avvisas" "$KOD" "400" + # 10. Live Share-behörighetsnivåer: kund/partner/intern + återkallelse curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ -d '{"handelser":[{"id":"h3","tidpunkt":"2026-08-03T08:03:00Z","anvandare":"Anna","handelse":{"typ":"hypotes","text":"Trasigt relä","niva":"lag"}}]}' >/dev/null diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index 043ca23..cbd9d13 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -147,6 +147,48 @@ paths: "403": { $ref: "#/components/responses/Fel" } "409": { $ref: "#/components/responses/Fel" } + /api/organisation: + get: + tags: [Organisation] + summary: Hämta organisationens namn och inställningar + description: > + Inställningarna styr vad som visas när ett ärende startas + (objekttyper och identifieringsmetoder). Alla inloggade läser. + responses: + "200": + description: Organisationen. + content: + application/json: + schema: + type: object + properties: + namn: { type: string } + installningar: { $ref: "#/components/schemas/OrganisationsInstallningar" } + "401": { $ref: "#/components/responses/Fel" } + + /api/organisation/installningar: + post: + tags: [Organisation] + summary: Uppdatera organisationens inställningar + description: Kräver rollen `admin`. Minst ett alternativ per lista. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OrganisationsInstallningar" } + responses: + "200": + description: Sparat. + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + /api/arenden: get: tags: [Ärenden] @@ -456,6 +498,18 @@ components: Roll: type: string enum: [tekniker, arbetsledare, admin] + OrganisationsInstallningar: + type: object + required: [objekttyper, identifieringsmetoder] + properties: + objekttyper: + type: array + minItems: 1 + items: { type: string } + identifieringsmetoder: + type: array + minItems: 1 + items: { type: string } DelningsNiva: type: string enum: [kund, partner, intern] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 257eba4..2b9acea 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -266,6 +266,34 @@ export function skapaServer() { } } + // Organisationens inställningar: vad som visas när ett ärende + // startas (objekttyper, identifieringsmetoder). Alla inloggade + // läser; endast systemadministratören ändrar. + if (req.method === "GET" && vag === "/api/organisation") { + const rader = await pool.query( + `select namn, installningar from organisationer where id = $1`, + [anspr.org], + ); + if (rader.rowCount === 0) return svara(res, 404, { error: "Organisationen finns inte." }); + return svara(res, 200, rader.rows[0]); + } + + if (req.method === "POST" && vag === "/api/organisation/installningar") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + const { objekttyper, identifieringsmetoder } = await lasKropp(req); + const giltigLista = (lista) => + Array.isArray(lista) && lista.length > 0 && lista.length <= 50 && + lista.every((v) => typeof v === "string" && v.length <= 100); + if (!giltigLista(objekttyper) || !giltigLista(identifieringsmetoder)) { + return svara(res, 400, { error: "Ange minst en objekttyp och en identifieringsmetod." }); + } + await pool.query( + `update organisationer set installningar = $2 where id = $1`, + [anspr.org, JSON.stringify({ objekttyper, identifieringsmetoder })], + ); + return svara(res, 200, { ok: true }); + } + // Organisationsöversikt för arbetsledare/admin: alla ärenden med // status, deltagande tekniker och sammanfattning — härlett ur // händelseloggen, aldrig lagrat separat. diff --git a/src/App.tsx b/src/App.tsx index 900c55c..be11b02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 625b770..39a1a83 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -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"], diff --git a/src/felsokning/__tests__/installningar.test.ts b/src/felsokning/__tests__/installningar.test.ts new file mode 100644 index 0000000..55e4612 --- /dev/null +++ b/src/felsokning/__tests__/installningar.test.ts @@ -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"]); + }); +}); diff --git a/src/felsokning/installningar.ts b/src/felsokning/installningar.ts new file mode 100644 index 0000000..c92451a --- /dev/null +++ b/src/felsokning/installningar.ts @@ -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; + 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 { + 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 { + 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}`); + } + } +} diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index 01c8c99..f68c101 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -62,6 +62,11 @@ function PlattformInloggning() { 📊 Organisationsöversikt )} + {konto.roll === "admin" && ( + + ⚙️ Inställningar för organisationen + + )} {konto.roll === "admin" && } @@ -300,6 +305,12 @@ export default function Arendelista() { {plattformAktiv() && } + {!plattformKonto() && ( + + ⚙️ Inställningar + + )} +

När du är inloggad hjälper AI:n dig medan du arbetar: föreslår nästa steg utifrån det du diff --git a/src/pages/felsokning/Installningar.tsx b/src/pages/felsokning/Installningar.tsx new file mode 100644 index 0000000..ec5b716 --- /dev/null +++ b/src/pages/felsokning/Installningar.tsx @@ -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 ( +

+ {alla.map((val) => { + const aktiv = valda.includes(val); + return ( + + ); + })} +
+ ); +} + +export default function Installningar() { + const konto = plattformKonto(); + const inloggad = plattformAktiv() && !!konto; + const farAndra = !inloggad || konto?.roll === "admin"; + + const [inst, setInst] = useState(lastaInstallningar()); + const [status, setStatus] = useState<"" | "sparar" | "sparat" | "fel">(""); + const [felText, setFelText] = useState(""); + + useEffect(() => { + hamtaInstallningar().then(setInst); + }, []); + + if (!farAndra) { + return ( + + +

+ Organisationens inställningar hanteras av er systemadministratör. +

+
+
+ ); + } + + 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) => { + setInst((f) => ({ ...f, ...nya })); + setStatus(""); + }; + + return ( + + +

+ 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."} +

+
+ + + byt({ objekttyper })} + /> + + + + byt({ identifieringsmetoder })} + /> + + + {status === "fel" &&

{felText}

} + {status === "sparat" &&

✓ Sparat

} + + {status === "sparar" ? "Sparar …" : "Spara inställningar"} + +
+ ); +} diff --git a/src/pages/felsokning/NyttArende.tsx b/src/pages/felsokning/NyttArende.tsx index 932fab6..de499e5 100644 --- a/src/pages/felsokning/NyttArende.tsx +++ b/src/pages/felsokning/NyttArende.tsx @@ -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() {
- {OBJEKTTYPER.map((t) => ( + {inst.objekttyper.map((t) => ( setTyp(t)}> {t} @@ -45,7 +54,7 @@ export default function NyttArende() {
- {IDENTIFIERINGSMETODER.map((m) => ( + {inst.identifieringsmetoder.map((m) => ( setMetod(m)}> {m}