diff --git a/docs/MVP.md b/docs/MVP.md index d3fb590..0b21da1 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -27,7 +27,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Röstinmatning (tal in, text ut) | ✅ Push-to-Talk via webbläsarens taligenkänning (sv-SE): lyssnar bara efter aktivt tryck, röd indikator med realtidstranskript, texten hamnar i ett redigerbart fält och skickas aldrig automatiskt. Knappen visas bara i webbläsare med talstöd. Produktionsversionen byter motor till leverantörens Voice-to-Text bakom samma gränssnitt. | | Verifierade checklistor | ✅ Varje kontroll i metodiken har ett minimikrav (foto, mätvärde eller kort observation). Foto-kontroller verifieras med bild; mätningar kan inte markeras verifierade utan värde. | | Export | ✅ Versionsmärkt JSON-export (version = antal händelser vid exporttillfället, med användare och tidpunkt); exporten loggas själv som händelse. PDF via utskrift. CSV och API i backend-fasen. | -| 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). | +| 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. **Ansvarig tekniker** per ärende härleds ur loggen (skapare → överlämning → omfördelning) och arbetsledaren kan omfördela pågående ärenden — loggat som den organisationsinterna händelsen `ansvarig_satt`, aldrig synlig i kund-/partnerdelningar. 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**: å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. | @@ -44,5 +44,4 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st ## Medvetna avgränsningar -- 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. diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 2e32909..8ce6e11 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -112,6 +112,16 @@ 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" +# 9b. Omfördelning: arbetsledaren listar användare och sätter ny ansvarig +ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_L" | falt .anvandare.length) +kontroll "arbetsledaren kan lista användare" "$ANTAL_ANV" "3" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_L" \ + -H 'Content-Type: application/json' -d '{"epost":"y@a.se","losenord":"hemligt123","namn":"Y","roll":"tekniker"}') +kontroll "arbetsledaren nekas skapa användare" "$KOD" "403" +curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_L" -H 'Content-Type: application/json' \ + -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" + # 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 @@ -126,7 +136,7 @@ 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" +kontroll "internnivån visar allt" "$(curl -s "$BAS/api/delad/$INTERNKOD" | falt .handelser.length)" "4" 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"}') diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index 91a72d7..043ca23 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -106,7 +106,7 @@ paths: get: tags: [Användare] summary: Lista organisationens användare - description: Kräver rollen `admin`. + description: Kräver rollen `admin` eller `arbetsledare`. responses: "200": description: Användare i den egna organisationen. @@ -512,6 +512,7 @@ components: - kategori_byte - inaktivitet_forklarad - overlamning + - ansvarig_satt - export_skapad - ai_svar - arende_avslutat diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 3bafb87..257eba4 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -222,7 +222,7 @@ export function skapaServer() { [arendeId], ); const bortfiltrerat = - niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar"] : ["kategori_byte", "hypotes", "ai_svar"]; + niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"]; const handelser = await pool.query( `select id, tidpunkt, anvandare, handelse from felsokning_handelser where arende_id = $1 and not (handelse->>'typ' = any($2)) @@ -238,7 +238,9 @@ export function skapaServer() { // Användarhantering: endast systemadministratör, endast egen org. if (vag === "/api/anvandare") { - if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + // Läsning: admin + arbetsledare (behövs för omfördelning). + // Skapande: endast admin. + if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." }); if (req.method === "GET") { const rader = await pool.query( `select id, epost, namn, roll from anvandare where organisation_id = $1 order by namn`, @@ -247,6 +249,7 @@ export function skapaServer() { return svara(res, 200, { anvandare: rader.rows }); } if (req.method === "POST") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const { epost, losenord, namn, roll } = await lasKropp(req); if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !ROLLER.includes(roll)) { return svara(res, 400, { error: "Ange namn, e-post, roll och lösenord (minst 8 tecken)." }); @@ -280,6 +283,10 @@ export function skapaServer() { filter (where h.handelse->>'typ' = 'objekt_identifierat'))[1] as objekt, (array_agg(h.handelse->>'text') filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning, + (array_agg(coalesce(h.handelse->>'ansvarig', h.handelse->>'till') order by h.tidpunkt desc) + filter (where h.handelse->>'typ' in ('ansvarig_satt', 'overlamning') + and coalesce(h.handelse->>'ansvarig', h.handelse->>'till') is not null))[1] as ansvarig, + (array_agg(h.anvandare order by h.tidpunkt asc))[1] as skapare, array_agg(distinct h.anvandare) filter (where h.anvandare is not null) as tekniker from felsokning_arenden a left join felsokning_handelser h on h.arende_id = a.id diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index 6b11568..78a38b5 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -32,7 +32,7 @@ export function DelatArendeVy({ const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); const kundposter = redanFiltrerad ? arende.handelser - : arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ)); + : arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"].includes(p.handelse.typ)); const pagaende = b.rekommenderatNastaSteg[0]; return ( diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index e982cfd..625b770 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -83,7 +83,7 @@ describe("AI-orkestern", () => { // … och AI-endpointen + händelsetyperna är dokumenterade. expect(spec).toContain("/api/ai:"); expect(spec).toContain("append-only"); - for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "arende_avslutat"]) { + for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "ansvarig_satt", "arende_avslutat"]) { expect(spec).toContain(typ); } }); diff --git a/src/felsokning/__tests__/projektioner.test.ts b/src/felsokning/__tests__/projektioner.test.ts index c8f6a56..42df53b 100644 --- a/src/felsokning/__tests__/projektioner.test.ts +++ b/src/felsokning/__tests__/projektioner.test.ts @@ -139,6 +139,27 @@ describe("tidsfordelning", () => { }); }); +describe("ansvarig", () => { + it("skaparen är ansvarig tills överlämning eller omfördelning pekar ut någon annan", async () => { + const { ansvarig } = await import("../projektioner"); + const bas: [string, Handelse][] = [["2026-08-03T08:00:00Z", OBJEKT]]; + expect(ansvarig(byggArende(bas))).toBe("Anna"); + + const efterOverlamning = byggArende([ + ...bas, + ["2026-08-03T09:00:00Z", { typ: "overlamning", fran: "Anna", till: "Johan" }], + ]); + expect(ansvarig(efterOverlamning)).toBe("Johan"); + + const efterOmfordelning = byggArende([ + ...bas, + ["2026-08-03T09:00:00Z", { typ: "overlamning", fran: "Anna", till: "Johan" }], + ["2026-08-03T10:00:00Z", { typ: "ansvarig_satt", ansvarig: "Lisa" }], + ]); + expect(ansvarig(efterOmfordelning)).toBe("Lisa"); + }); +}); + describe("brief", () => { const arende = byggArende([ ["2026-08-02T08:03:00Z", OBJEKT], diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index ffc3e97..1eed0c2 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -51,6 +51,7 @@ export type Handelse = | { typ: "kategori_byte"; kategori: TidKategori } | { typ: "inaktivitet_forklarad"; text: string; minuter: number } | { typ: "overlamning"; fran: string; till?: string } + | { typ: "ansvarig_satt"; ansvarig: string } | { typ: "export_skapad"; format: string; version: number } | { typ: "ai_svar"; @@ -119,6 +120,8 @@ export function handelseRubrik(post: LoggPost): string { return `Komplettering (${h.minuter} min utan aktivitet): ${h.text}`; case "overlamning": return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`; + case "ansvarig_satt": + return `Ansvarig tekniker: ${h.ansvarig}`; case "export_skapad": return `Export skapad: ${h.format}, version ${h.version}`; case "ai_svar": { diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index d2f0e53..d674d86 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -141,6 +141,8 @@ export interface OversiktsRad { avslutat: boolean; objekt: string | null; felbeskrivning: string | null; + ansvarig: string | null; + skapare: string | null; tekniker: string[] | null; } diff --git a/src/felsokning/projektioner.ts b/src/felsokning/projektioner.ts index 1533443..20d60e1 100644 --- a/src/felsokning/projektioner.ts +++ b/src/felsokning/projektioner.ts @@ -21,6 +21,18 @@ export function felbeskrivning(arende: Arende): string | undefined { return undefined; } +// Ansvarig tekniker härleds ur loggen: skaparen, tills en överlämning +// med mottagare eller en omfördelning (ansvarig_satt) pekar ut någon annan. +export function ansvarig(arende: Arende): string | undefined { + let namn = arende.handelser[0]?.anvandare; + for (const post of arende.handelser) { + const h = post.handelse; + if (h.typ === "ansvarig_satt") namn = h.ansvarig; + if (h.typ === "overlamning" && h.till) namn = h.till; + } + return namn; +} + export function arAvslutat(arende: Arende): boolean { return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat"); } @@ -161,6 +173,7 @@ export function tillforlitlighet(arende: Arende): TillforlitlighetsRad[] { export interface Brief { objekt?: Objekt; + ansvarig?: string; felbeskrivning?: string; utfordaKontroller: UtfordKontroll[]; observationer: string[]; @@ -190,6 +203,7 @@ export function brief(arende: Arende, metodik: Metodik, nu?: string): Brief { } return { objekt: objekt(arende), + ansvarig: ansvarig(arende), felbeskrivning: felbeskrivning(arende), utfordaKontroller: utfordaKontroller(arende), observationer: observationer(arende), diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index 558e07d..7108228 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -796,6 +796,7 @@ function BriefFlik({

{b.objekt.beskrivning}

{b.objekt.identifierare}

{b.objekt.kund &&

Kund: {b.objekt.kund}

} + {b.ansvarig &&

Ansvarig tekniker: {b.ansvarig}

} ) : (

Ej identifierat

@@ -875,7 +876,7 @@ function RapportFlik({ // Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial // och ingår inte i det som delas med kund. const kundposter = arende.handelser.filter( - (p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ), + (p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"].includes(p.handelse.typ), ); // Alla exporter bygger på samma händelselogg och versionsmärks: diff --git a/src/pages/felsokning/Oversikt.tsx b/src/pages/felsokning/Oversikt.tsx index 9f5b7fe..d26e3e5 100644 --- a/src/pages/felsokning/Oversikt.tsx +++ b/src/pages/felsokning/Oversikt.tsx @@ -7,18 +7,77 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { Arende } from "@/felsokning/domain"; import { + hamtaAnvandare, hamtaOversikt, plattformAktiv, plattformFetch, plattformKonto, type OversiktsRad, + type PlattformAnvandare, } from "@/felsokning/plattform"; +import { nyLoggPost } from "@/felsokning/domain"; import { flataIhop } from "@/felsokning/synk"; import { useFelsokning } from "@/felsokning/store"; import { formateraTid } from "@/felsokning/projektioner"; import { FelsokningSkal, Panel, StorKnapp } from "@/felsokning/ui"; import { tidDatum, tidKlockslag } from "@/felsokning/format"; +// Omfördelning: arbetsledaren pekar ut ny ansvarig tekniker — loggas som +// append-only-händelsen ansvarig_satt i ärendets logg (organisationsintern, +// delas aldrig i kund-/partnervyer). +function Omfordela({ rad, vidKlar }: { rad: OversiktsRad; vidKlar: () => void }) { + const [oppen, setOppen] = useState(false); + const [personer, setPersoner] = useState([]); + const konto = plattformKonto(); + + if (!oppen) { + return ( + { + setOppen(true); + hamtaAnvandare().then(setPersoner).catch(() => setPersoner([])); + }} + > + Omfördela + + ); + } + return ( +
+

Ny ansvarig tekniker

+
+ {personer.map((person) => ( + + ))} + +
+
+ ); +} + export default function Oversikt() { const navigate = useNavigate(); const { arenden, laggInArende, sammanfoga } = useFelsokning(); @@ -138,13 +197,16 @@ export default function Oversikt() {

{rad.objekt ?? "Okänt objekt"}

{rad.felbeskrivning &&

”{rad.felbeskrivning}”

}

- {rad.antal_handelser} händelser - {rad.tekniker?.length ? ` · ${rad.tekniker.join(", ")}` : ""} + Ansvarig: {rad.ansvarig ?? rad.skapare ?? "—"} + {` · ${rad.antal_handelser} händelser`} {rad.senaste ? ` · senast ${tidDatum(rad.senaste)} ${tidKlockslag(rad.senaste)}` : ""}

- oppna(rad)}> - Öppna ärendet - +
+ oppna(rad)}> + Öppna ärendet + + {!rad.avslutat && hamtaOversikt().then(setRader)} />} +
))} diff --git a/supabase/migrations/20260802230000_guidad_felsokning.sql b/supabase/migrations/20260802230000_guidad_felsokning.sql index 780e98e..f811a2b 100644 --- a/supabase/migrations/20260802230000_guidad_felsokning.sql +++ b/supabase/migrations/20260802230000_guidad_felsokning.sql @@ -59,7 +59,7 @@ as $$ select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id) from felsokning_handelser h where h.arende_id = a.id - and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar') + and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt') ), '[]'::jsonb) ) from felsokning_arenden a