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
+1
View File
@@ -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. | | 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. | | 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. | | 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. | | 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`. | | Ö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`. |
+4
View File
@@ -9,8 +9,12 @@ create extension if not exists pgcrypto;
create table if not exists organisationer ( create table if not exists organisationer (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
namn text not null, 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() 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 ( create table if not exists anvandare (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
+17
View File
@@ -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 -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" 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 # 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' \ 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 -d '{"handelser":[{"id":"h3","tidpunkt":"2026-08-03T08:03:00Z","anvandare":"Anna","handelse":{"typ":"hypotes","text":"Trasigt relä","niva":"lag"}}]}' >/dev/null
+54
View File
@@ -147,6 +147,48 @@ paths:
"403": { $ref: "#/components/responses/Fel" } "403": { $ref: "#/components/responses/Fel" }
"409": { $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: /api/arenden:
get: get:
tags: [Ärenden] tags: [Ärenden]
@@ -456,6 +498,18 @@ components:
Roll: Roll:
type: string type: string
enum: [tekniker, arbetsledare, admin] 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: DelningsNiva:
type: string type: string
enum: [kund, partner, intern] enum: [kund, partner, intern]
+28
View File
@@ -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 // Organisationsöversikt för arbetsledare/admin: alla ärenden med
// status, deltagande tekniker och sammanfattning — härlett ur // status, deltagande tekniker och sammanfattning — härlett ur
// händelseloggen, aldrig lagrat separat. // händelseloggen, aldrig lagrat separat.
+2
View File
@@ -20,6 +20,7 @@ import ArendeSida from "./pages/felsokning/ArendeSida";
import DelatArende from "./pages/felsokning/DelatArende"; import DelatArende from "./pages/felsokning/DelatArende";
import PublikDelning from "./pages/felsokning/PublikDelning"; import PublikDelning from "./pages/felsokning/PublikDelning";
import Oversikt from "./pages/felsokning/Oversikt"; import Oversikt from "./pages/felsokning/Oversikt";
import Installningar from "./pages/felsokning/Installningar";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner"; import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -49,6 +50,7 @@ function AppContent() {
<Route path="/felsokning/dela/:id" element={<DelatArende />} /> <Route path="/felsokning/dela/:id" element={<DelatArende />} />
<Route path="/felsokning/delad/:kod" element={<PublikDelning />} /> <Route path="/felsokning/delad/:kod" element={<PublikDelning />} />
<Route path="/felsokning/oversikt" element={<Oversikt />} /> <Route path="/felsokning/oversikt" element={<Oversikt />} />
<Route path="/felsokning/installningar" element={<Installningar />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
+2
View File
@@ -71,6 +71,8 @@ describe("AI-orkestern", () => {
["/api/auth/registrera", "/api/auth/registrera"], ["/api/auth/registrera", "/api/auth/registrera"],
["/api/auth/logga-in", "/api/auth/logga-in"], ["/api/auth/logga-in", "/api/auth/logga-in"],
["/api/anvandare", "/api/anvandare"], ["/api/anvandare", "/api/anvandare"],
["/api/organisation", "/api/organisation"],
["/api/organisation/installningar", "/api/organisation/installningar"],
["/api/arenden", "/api/arenden"], ["/api/arenden", "/api/arenden"],
["/api/arenden/{arendeId}/handelser", "handelser"], ["/api/arenden/{arendeId}/handelser", "handelser"],
["/api/oversikt", "/api/oversikt"], ["/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> <StorKnapp variant="sekundar">📊 Organisationsöversikt</StorKnapp>
</Link> </Link>
)} )}
{konto.roll === "admin" && (
<Link to="/felsokning/installningar" className="mt-2 block">
<StorKnapp variant="sekundar"> Inställningar för organisationen</StorKnapp>
</Link>
)}
</Panel> </Panel>
{konto.roll === "admin" && <AnvandarAdmin />} {konto.roll === "admin" && <AnvandarAdmin />}
</> </>
@@ -300,6 +305,12 @@ export default function Arendelista() {
{plattformAktiv() && <PlattformInloggning />} {plattformAktiv() && <PlattformInloggning />}
{!plattformKonto() && (
<Link to="/felsokning/installningar" className="mb-4 block">
<StorKnapp variant="sekundar"> Inställningar</StorKnapp>
</Link>
)}
<Panel rubrik="AI-stöd"> <Panel rubrik="AI-stöd">
<p className="text-zinc-300"> <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 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 { useNavigate } from "react-router-dom";
import type { Objekt } from "@/felsokning/domain"; import type { Objekt } from "@/felsokning/domain";
import { useFelsokning } from "@/felsokning/store"; import { useFelsokning } from "@/felsokning/store";
import { valjMetodik } from "@/felsokning/metodik"; import { valjMetodik } from "@/felsokning/metodik";
import { valjMetodikMedAi } from "@/felsokning/ai"; import { valjMetodikMedAi } from "@/felsokning/ai";
import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; 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. // 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() { export default function NyttArende() {
const navigate = useNavigate(); const navigate = useNavigate();
const skapaArende = useFelsokning((s) => s.skapaArende); const skapaArende = useFelsokning((s) => s.skapaArende);
const [inst, setInst] = useState(lastaInstallningar());
const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera"); const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera");
const [metod, setMetod] = useState(IDENTIFIERINGSMETODER[0]); const [metod, setMetod] = useState(inst.identifieringsmetoder[0]);
const [typ, setTyp] = useState(OBJEKTTYPER[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 [identifierare, setIdentifierare] = useState("");
const [beskrivning, setBeskrivning] = useState(""); const [beskrivning, setBeskrivning] = useState("");
const [kund, setKund] = useState(""); const [kund, setKund] = useState("");
@@ -36,7 +45,7 @@ export default function NyttArende() {
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}> <FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Objekttyp"> <Panel rubrik="Objekttyp">
<div className="grid grid-cols-2 gap-2"> <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)}> <StorKnapp key={t} variant={t === typ ? "primar" : "sekundar"} onClick={() => setTyp(t)}>
{t} {t}
</StorKnapp> </StorKnapp>
@@ -45,7 +54,7 @@ export default function NyttArende() {
</Panel> </Panel>
<Panel rubrik="Identifieringsmetod"> <Panel rubrik="Identifieringsmetod">
<div className="grid grid-cols-2 gap-2"> <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)}> <StorKnapp key={m} variant={m === metod ? "primar" : "sekundar"} onClick={() => setMetod(m)}>
{m} {m}
</StorKnapp> </StorKnapp>