diff --git a/docs/MVP.md b/docs/MVP.md index f4b337d..d3fb590 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -30,7 +30,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. **Arbetsledarvy** (`/felsokning/oversikt`): organisationens alla ärenden med status, deltagande tekniker och statistik (pågående/avslutade/ledtid) — härlett ur händelseloggen; ärenden kan hämtas till enheten med konfliktfri flätning. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only, översiktens behörighet och härledningar). | | Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. | | 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 (kund/intern/partner) i nästa fas. | +| 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. | | 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`. | @@ -46,4 +46,3 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st - Omfördelning av ärenden (arbetsledare) är inte implementerad — överlämningshändelsen täcker handover tills en ansvarig-modell införs. - QR/streckkod/OCR för objektidentifiering, bildanalys och tillverkarintegrationer ingår inte ännu. -- Live Share-behörighetsnivåerna (kund/intern/partner) är specificerade men inte byggda — dagens delningslänk motsvarar kundnivån. diff --git a/docs/moduler/live-share.md b/docs/moduler/live-share.md index 4bcbad7..b054240 100644 --- a/docs/moduler/live-share.md +++ b/docs/moduler/live-share.md @@ -39,7 +39,9 @@ Länkar kan skapas med olika åtkomstnivåer: - **Kund** – läsbehörighet till den information verkstaden valt att dela. - **Intern** – full insyn för kollegor och arbetsledare. -- **Extern partner** – exempelvis försäkringsbolag eller tillverkare, med avgränsad information. +- **Extern partner** – exempelvis försäkringsbolag eller tillverkare, med avgränsad information (inklusive hypoteser, tydligt märkta som ej verifierade). + +Implementerat i plattformen: varje länk skapas med en nivå, filtreringen sker på serversidan och länkar kan återkallas — en återkallad länk ger 404. ## Export diff --git a/infra/k8s/postgres-init.sql b/infra/k8s/postgres-init.sql index 00f8c29..31b8df1 100644 --- a/infra/k8s/postgres-init.sql +++ b/infra/k8s/postgres-init.sql @@ -62,3 +62,16 @@ drop trigger if exists arenden_append_only on felsokning_arenden; create trigger arenden_append_only before update or delete on felsokning_arenden for each row execute function forbjud_andring(); + +-- Live Share-delningar: återkallbara länkar med behörighetsnivå. +-- (Åtkomststyrning, inte journal — därför ingen append-only-trigger: +-- återkallelse sätter aterkallad-tidpunkten.) +create table if not exists delningar ( + kod text primary key, + arende_id text not null references felsokning_arenden(id), + niva text not null check (niva in ('kund', 'partner', 'intern')), + skapad_av uuid references anvandare(id), + skapad timestamptz not null default now(), + aterkallad timestamptz +); +create index if not exists delningar_arende_idx on delningar (arende_id); diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 18d183d..2e32909 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -112,7 +112,31 @@ kontroll "översikten härleder felbeskrivning" "$(echo "$OVERSIKT" | falt '.are kontroll "översikten härleder status" "$(echo "$OVERSIKT" | falt '.arenden[0].avslutat')" "false" kontroll "översikten räknar händelser" "$(echo "$OVERSIKT" | falt '.arenden[0].antal_handelser')" "2" -# 10. API-first: OpenAPI-specen serveras live, utan inloggning +# 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 +kontroll "kundkoden filtrerar hypoteser" "$(curl -s "$BAS/api/delad/delkod123" | falt .handelser.length)" "1" + +PARTNERKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"niva":"partner"}' | falt .kod) +PARTNER=$(curl -s "$BAS/api/delad/$PARTNERKOD") +kontroll "partnernivån visar hypoteser" "$(echo "$PARTNER" | falt .handelser.length)" "2" +kontroll "partnernivån döljer kategoribyten" "$(echo "$PARTNER" | falt '.handelser.some(h=>h.handelse.typ==="kategori_byte")')" "false" +kontroll "nivån följer med svaret" "$(echo "$PARTNER" | falt .niva)" "partner" + +INTERNKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"niva":"intern"}' | falt .kod) +kontroll "internnivån visar allt" "$(curl -s "$BAS/api/delad/$INTERNKOD" | falt .handelser.length)" "3" + +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/delningar" \ + -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"niva":"intern"}') +kontroll "org B kan inte skapa delning av org A:s ärende" "$KOD" "404" + +curl -s -X POST "$BAS/api/delningar/$PARTNERKOD/aterkalla" -H "Authorization: Bearer $TOKEN_A" >/dev/null +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$PARTNERKOD") +kontroll "återkallad delning ger 404" "$KOD" "404" + +# 11. API-first: OpenAPI-specen serveras live, utan inloggning SPEC=$(curl -s "$BAS/api/openapi.yaml") case "$SPEC" in "openapi: 3.0.3"*) echo "✓ OpenAPI-specen serveras på /api/openapi.yaml" ;; diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index 0f811f0..91a72d7 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -269,22 +269,99 @@ paths: "401": { $ref: "#/components/responses/Fel" } "403": { $ref: "#/components/responses/Fel" } + /api/arenden/{arendeId}/delningar: + parameters: + - name: arendeId + in: path + required: true + schema: { type: string } + get: + tags: [Delning] + summary: Lista ärendets delningslänkar + responses: + "200": + description: Delningar (inklusive återkallade). + content: + application/json: + schema: + type: object + properties: + delningar: + type: array + items: { $ref: "#/components/schemas/Delning" } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + post: + tags: [Delning] + summary: Skapa delningslänk med behörighetsnivå + description: > + Verkstaden kontrollerar alltid delningen: kund (det kunddelbara), + partner (även hypoteser, tydligt märkta ej verifierade) eller + intern (full insyn). Nivåfiltreringen sker på serversidan. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [niva] + properties: + niva: { $ref: "#/components/schemas/DelningsNiva" } + responses: + "200": + description: Den skapade länkens kod. + content: + application/json: + schema: + type: object + properties: + kod: { type: string } + niva: { $ref: "#/components/schemas/DelningsNiva" } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + + /api/delningar/{kod}/aterkalla: + post: + tags: [Delning] + summary: Återkalla en delningslänk + description: Efter återkallelse ger länken 404. Kan inte ångras. + parameters: + - name: kod + in: path + required: true + schema: { type: string } + responses: + "200": + description: Återkallad. + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + /api/delad/{delningskod}: get: tags: [Delning] summary: Publik Live Share-vy via delningskod description: > - Kräver ingen inloggning — delningskoden är nyckeln. Interna poster - (kategoribyten, hypoteser, AI-dialog) är bortfiltrerade. + Kräver ingen inloggning — delningskoden är nyckeln. Filtreringen + styrs av länkens behörighetsnivå: kund utesluter kategoribyten, + hypoteser och AI-dialog; partner utesluter kategoribyten och + AI-dialog; intern visar allt. Ärendets ursprungliga delningskod + fungerar bakåtkompatibelt som kundnivå. security: [] parameters: - name: delningskod in: path required: true - schema: { type: string, pattern: "^[a-z0-9-]+$" } + schema: { type: string, pattern: "^[A-Za-z0-9_-]+$" } responses: "200": - description: Ärendet med kunddelbar händelselogg. + description: Ärendet med nivåfiltrerad händelselogg. content: application/json: schema: @@ -299,6 +376,7 @@ paths: handelser: type: array items: { $ref: "#/components/schemas/LoggPost" } + niva: { $ref: "#/components/schemas/DelningsNiva" } "404": { $ref: "#/components/responses/Fel" } /api/ai: @@ -378,6 +456,16 @@ components: Roll: type: string enum: [tekniker, arbetsledare, admin] + DelningsNiva: + type: string + enum: [kund, partner, intern] + Delning: + type: object + properties: + kod: { type: string } + niva: { $ref: "#/components/schemas/DelningsNiva" } + skapad: { type: string, format: date-time } + aterkallad: { type: string, format: date-time, nullable: true } Anvandare: type: object properties: diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 49e3dd9..3bafb87 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -15,7 +15,7 @@ // PORT default 8080 import { createServer } from "node:http"; -import { createHmac, timingSafeEqual } from "node:crypto"; +import crypto, { createHmac, timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -80,6 +80,12 @@ async function lasKropp(req) { return JSON.parse(Buffer.concat(bitar).toString("utf8")); } +function nyKod() { + const tecken = "abcdefghijklmnopqrstuvwxyz0123456789"; + const { randomBytes } = crypto; + return Array.from(randomBytes(16), (b) => tecken[b % 36]).join(""); +} + function kravAuth(req, hemlighet) { const auth = req.headers.authorization ?? ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; @@ -186,20 +192,44 @@ export function skapaServer() { } // -- Publik delning (Live Share via delningskod) -- - const delad = vag.match(/^\/api\/delad\/([a-z0-9-]+)$/); + // Behörighetsnivån styr filtreringen på serversidan: + // kund – det kunddelbara (inga kategoribyten, hypoteser, AI-dialog) + // partner – försäkringsbolag/tillverkare: även hypoteser (tydligt märkta) + // intern – full insyn + const delad = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)$/); if (req.method === "GET" && delad) { - const arende = await pool.query( - `select id, nummer, skapad from felsokning_arenden where delningskod = $1`, + let arendeId = null; + let niva = "kund"; + const delning = await pool.query( + `select arende_id, niva from delningar where kod = $1 and aterkallad is null`, [delad[1]], ); - if (arende.rowCount === 0) return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); + if (delning.rowCount > 0) { + arendeId = delning.rows[0].arende_id; + niva = delning.rows[0].niva; + } else { + // Bakåtkompatibelt: ärendets ursprungliga delningskod = kundnivå. + const viaArende = await pool.query( + `select id from felsokning_arenden where delningskod = $1`, + [delad[1]], + ); + if (viaArende.rowCount > 0) arendeId = viaArende.rows[0].id; + } + if (!arendeId) return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); + + const arende = await pool.query( + `select id, nummer, skapad from felsokning_arenden where id = $1`, + [arendeId], + ); + const bortfiltrerat = + niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar"] : ["kategori_byte", "hypotes", "ai_svar"]; const handelser = await pool.query( `select id, tidpunkt, anvandare, handelse from felsokning_handelser - where arende_id = $1 and handelse->>'typ' not in ('kategori_byte','hypotes','ai_svar') + where arende_id = $1 and not (handelse->>'typ' = any($2)) order by tidpunkt, id`, - [arende.rows[0].id], + [arendeId, bortfiltrerat], ); - return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows }); + return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); } // -- Skyddade endpoints (organisationsknutna) -- @@ -284,6 +314,47 @@ export function skapaServer() { return svara(res, 200, { ok: true }); } + // Delningslänkar: skapa/lista per ärende, återkalla per kod. + // Verkstaden kontrollerar alltid delningen (organisationskravet). + const delningarVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/delningar$/); + if (delningarVag) { + if (!(await arendeIOrg(delningarVag[1], anspr.org))) { + return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); + } + if (req.method === "GET") { + const rader = await pool.query( + `select kod, niva, skapad, aterkallad from delningar where arende_id = $1 order by skapad desc`, + [delningarVag[1]], + ); + return svara(res, 200, { delningar: rader.rows }); + } + if (req.method === "POST") { + const { niva } = await lasKropp(req); + if (!["kund", "partner", "intern"].includes(niva)) { + return svara(res, 400, { error: "Ogiltig nivå." }); + } + const kod = nyKod(); + await pool.query( + `insert into delningar (kod, arende_id, niva, skapad_av) values ($1, $2, $3, $4)`, + [kod, delningarVag[1], niva, anspr.sub], + ); + return svara(res, 200, { kod, niva }); + } + } + + const aterkalla = vag.match(/^\/api\/delningar\/([A-Za-z0-9_-]+)\/aterkalla$/); + if (req.method === "POST" && aterkalla) { + const rad = await pool.query( + `update delningar d set aterkallad = now() + from felsokning_arenden a + where d.kod = $1 and d.arende_id = a.id and a.organisation_id = $2 and d.aterkallad is null + returning d.kod`, + [aterkalla[1], anspr.org], + ); + if (rad.rowCount === 0) return svara(res, 404, { error: "Delningen är inte tillgänglig." }); + return svara(res, 200, { ok: true }); + } + const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/); if (handelserVag) { // Organisationsgränsen: ärendet måste tillhöra användarens org. diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index a1f1f8d..6b11568 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -11,15 +11,28 @@ import { metodikForArende } from "./store"; import { tidDatum, tidKlockslag } from "./format"; import { FelsokningSkal, Panel } from "./ui"; -export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: string; notis: string }) { +export function DelatArendeVy({ + arende, + nu, + notis, + redanFiltrerad = false, +}: { + arende: Arende; + nu: string; + notis: string; + // Sant när servern redan filtrerat per behörighetsnivå (publik delning) — + // då renderas händelserna som de kom, inklusive t.ex. hypoteser på + // partner-/internnivå. + redanFiltrerad?: boolean; +}) { const metodik = metodikForArende(arende); const b = brief(arende, metodik, nu); const avslutat = arAvslutat(arende); const bilder = foton(arende); const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); - const kundposter = arende.handelser.filter( - (p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ), - ); + const kundposter = redanFiltrerad + ? arende.handelser + : arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ)); const pagaende = b.rekommenderatNastaSteg[0]; return ( diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index 4e8546f..d2f0e53 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -98,6 +98,36 @@ export async function skapaAnvandare( } } +// Live Share-delningar: återkallbara länkar med behörighetsnivå. +export type DelningsNiva = "kund" | "partner" | "intern"; + +export interface Delning { + kod: string; + niva: DelningsNiva; + skapad: string; + aterkallad: string | null; +} + +export async function hamtaDelningar(arendeId: string): Promise { + const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return ((await res.json()) as { delningar: Delning[] }).delningar; +} + +export async function skapaDelning(arendeId: string, niva: DelningsNiva): Promise { + const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`, { + method: "POST", + body: JSON.stringify({ niva }), + }); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return ((await res.json()) as { kod: string }).kod; +} + +export async function aterkallaDelning(kod: string): Promise { + const res = await plattformFetch(`/api/delningar/${kod}/aterkalla`, { method: "POST" }); + if (!res.ok) throw new Error(`Fel ${res.status}`); +} + // Organisationsöversikt (arbetsledare/admin; servern verifierar). export interface OversiktsRad { id: string; diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index 1957687..558e07d 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -18,6 +18,14 @@ import { } from "@/felsokning/projektioner"; import { metodikForArende, useFelsokning } from "@/felsokning/store"; import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk"; +import { + aterkallaDelning, + hamtaDelningar, + plattformAktiv, + skapaDelning, + type Delning, + type DelningsNiva, +} from "@/felsokning/plattform"; import { AI_RADTYP_LABEL, fragaAi, granskaUnderlag, sammanfattaOverlamning } from "@/felsokning/ai"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { skalaNerFoto, tidKlockslag } from "@/felsokning/format"; @@ -605,6 +613,104 @@ function OverlamningDialog({ ); } +const NIVA_LABEL: Record = { + kund: "Kund", + partner: "Extern partner", + intern: "Intern", +}; + +// Live Share-behörighetsnivåer: skapa och återkalla delningslänkar. +// Nivåfiltreringen sker alltid på serversidan; verkstaden kontrollerar +// delningen och varje skapad/återkallad länk loggas i ärendet. +function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) { + const [oppen, setOppen] = useState(false); + const [delningar, setDelningar] = useState([]); + const [fel, setFel] = useState(""); + + const uppdatera = async () => { + try { + setDelningar(await hamtaDelningar(arende.id)); + setFel(""); + } catch { + setFel("Kunde inte hämta delningar — kräver inloggning och att ärendet är synkat."); + } + }; + + if (!oppen) { + return ( + { + setOppen(true); + uppdatera(); + }} + > + 🔗 Delningslänkar (kund/partner/intern) + + ); + } + + return ( +
+ {fel &&

{fel}

} + {delningar.map((delning) => ( +
+ + {NIVA_LABEL[delning.niva]}{" "} + {delning.aterkallad && (återkallad)} + + {!delning.aterkallad && ( + + + + + )} +
+ ))} +
+ {(Object.keys(NIVA_LABEL) as DelningsNiva[]).map((niva) => ( + + ))} +
+

+ Kund: det kunddelbara · Extern partner: även hypoteser (märkta ej verifierade) · Intern: full insyn. + Filtreringen sker på servern. +

+
+ ); +} + function LoggFlik({ arende }: { arende: Arende }) { return ( @@ -808,17 +914,21 @@ function RapportFlik({ 🟢 Öppna Live Share-vy - {arende.delningskod && ( - { - navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`); - skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." }); - }} - > - 🔗 Kopiera delningslänk - + {plattformAktiv() ? ( + + ) : ( + arende.delningskod && ( + { + navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`); + skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." }); + }} + > + 🔗 Kopiera delningslänk + + ) )}

Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare). diff --git a/src/pages/felsokning/PublikDelning.tsx b/src/pages/felsokning/PublikDelning.tsx index 80e010f..3446406 100644 --- a/src/pages/felsokning/PublikDelning.tsx +++ b/src/pages/felsokning/PublikDelning.tsx @@ -12,9 +12,17 @@ import { FelsokningSkal, Panel } from "@/felsokning/ui"; interface DelatSvar { arende: { id: string; nummer: number; skapad: string }; handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Handelse }[]; + niva?: "kund" | "partner" | "intern"; } -async function hamtaDelat(kod: string): Promise { +const NIVA_NOTIS: Record = { + kund: "Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras.", + partner: + "Skrivskyddad livevy (partnernivå) — inkluderar tekniska hypoteser, tydligt märkta som ej verifierade. Uppdateras automatiskt.", + intern: "Skrivskyddad livevy (intern nivå) — full insyn i ärendet. Uppdateras automatiskt.", +}; + +async function hamtaDelat(kod: string): Promise<{ arende: Arende; niva: string } | undefined> { const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform"); let data: unknown; if (plattformAktiv()) { @@ -32,21 +40,25 @@ async function hamtaDelat(kod: string): Promise { } const svar = data as DelatSvar; return { - id: svar.arende.id, - nummer: svar.arende.nummer, - skapad: new Date(svar.arende.skapad).toISOString(), - handelser: svar.handelser.map((rad) => ({ - id: rad.id, - tidpunkt: new Date(rad.tidpunkt).toISOString(), - anvandare: rad.anvandare, - handelse: rad.handelse, - })), + niva: svar.niva ?? "kund", + arende: { + id: svar.arende.id, + nummer: svar.arende.nummer, + skapad: new Date(svar.arende.skapad).toISOString(), + handelser: svar.handelser.map((rad) => ({ + id: rad.id, + tidpunkt: new Date(rad.tidpunkt).toISOString(), + anvandare: rad.anvandare, + handelse: rad.handelse, + })), + }, }; } export default function PublikDelning() { const { kod } = useParams<{ kod: string }>(); const [arende, setArende] = useState(); + const [niva, setNiva] = useState("kund"); const [status, setStatus] = useState<"laddar" | "klar" | "saknas">("laddar"); const [nu, setNu] = useState(() => new Date().toISOString()); @@ -57,7 +69,8 @@ export default function PublikDelning() { try { const resultat = await hamtaDelat(kod); if (!aktiv) return; - setArende(resultat); + setArende(resultat?.arende); + if (resultat) setNiva(resultat.niva); setStatus(resultat ? "klar" : "saknas"); setNu(new Date().toISOString()); } catch { @@ -90,7 +103,8 @@ export default function PublikDelning() { ); }