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