Ansvarig tekniker och omfördelning från arbetsledarvyn

Ansvarig per ärende härleds ur händelseloggen: skaparen är ansvarig
tills en överlämning eller den nya händelsen ansvarig_satt pekar ut
någon annan. Arbetsledaren kan omfördela pågående ärenden direkt i
översikten; händelsen är organisationsintern och filtreras bort ur
kund- och partnerdelningar i klient, plattformstjänst och Supabase-RPC.

- Ny händelsetyp ansvarig_satt + projektion ansvarig() (testad)
- Briefen visar ansvarig tekniker; översikten visar ansvarig/skapare
- Arbetsledare får lista användare (skapa kräver fortfarande admin)
- Översikts-SQL:en härleder ansvarig och skapare ur loggen
- OpenAPI-specen uppdaterad; integrationstestet utökat (omfördelning,
  behörigheter, delningsnivåernas filtrering)

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:17:35 +00:00
parent 150b5011d5
commit 4161bbf606
13 changed files with 135 additions and 15 deletions
+1 -2
View File
@@ -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.
+11 -1
View File
@@ -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"}')
+2 -1
View File
@@ -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
+9 -2
View File
@@ -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
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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);
}
});
@@ -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],
+3
View File
@@ -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": {
+2
View File
@@ -141,6 +141,8 @@ export interface OversiktsRad {
avslutat: boolean;
objekt: string | null;
felbeskrivning: string | null;
ansvarig: string | null;
skapare: string | null;
tekniker: string[] | null;
}
+14
View File
@@ -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),
+2 -1
View File
@@ -796,6 +796,7 @@ function BriefFlik({
<p className="text-xl font-extrabold">{b.objekt.beskrivning}</p>
<p className="font-bold text-amber-400">{b.objekt.identifierare}</p>
{b.objekt.kund && <p className="text-zinc-300">Kund: {b.objekt.kund}</p>}
{b.ansvarig && <p className="text-zinc-300">Ansvarig tekniker: {b.ansvarig}</p>}
</>
) : (
<p className="text-zinc-400">Ej identifierat</p>
@@ -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:
+67 -5
View File
@@ -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<PlattformAnvandare[]>([]);
const konto = plattformKonto();
if (!oppen) {
return (
<StorKnapp
variant="sekundar"
onClick={() => {
setOppen(true);
hamtaAnvandare().then(setPersoner).catch(() => setPersoner([]));
}}
>
Omfördela
</StorKnapp>
);
}
return (
<div className="col-span-2 rounded-lg border-2 border-zinc-700 bg-zinc-950 p-2">
<p className="mb-2 text-sm font-bold uppercase text-zinc-400">Ny ansvarig tekniker</p>
<div className="grid grid-cols-2 gap-2">
{personer.map((person) => (
<button
key={person.id}
className="min-h-12 rounded-lg border-2 border-zinc-600 font-extrabold text-zinc-200 hover:border-amber-400"
onClick={async () => {
const post = nyLoggPost(konto?.namn ?? "arbetsledare", {
typ: "ansvarig_satt",
ansvarig: person.namn,
});
await plattformFetch(`/api/arenden/${rad.id}/handelser`, {
method: "POST",
body: JSON.stringify({ handelser: [post] }),
});
setOppen(false);
vidKlar();
}}
>
{person.namn}
</button>
))}
<button
className="min-h-12 rounded-lg border-2 border-zinc-600 font-bold text-zinc-400"
onClick={() => setOppen(false)}
>
Avbryt
</button>
</div>
</div>
);
}
export default function Oversikt() {
const navigate = useNavigate();
const { arenden, laggInArende, sammanfoga } = useFelsokning();
@@ -138,13 +197,16 @@ export default function Oversikt() {
<p className="mt-1 text-xl font-extrabold">{rad.objekt ?? "Okänt objekt"}</p>
{rad.felbeskrivning && <p className="text-lg text-zinc-200">{rad.felbeskrivning}</p>}
<p className="mt-1 text-sm text-zinc-500">
{rad.antal_handelser} händelser
{rad.tekniker?.length ? ` · ${rad.tekniker.join(", ")}` : ""}
Ansvarig: <span className="font-bold text-zinc-300">{rad.ansvarig ?? rad.skapare ?? "—"}</span>
{` · ${rad.antal_handelser} händelser`}
{rad.senaste ? ` · senast ${tidDatum(rad.senaste)} ${tidKlockslag(rad.senaste)}` : ""}
</p>
<StorKnapp variant="sekundar" className="mt-2" onClick={() => oppna(rad)}>
Öppna ärendet
</StorKnapp>
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => oppna(rad)}>
Öppna ärendet
</StorKnapp>
{!rad.avslutat && <Omfordela rad={rad} vidKlar={() => hamtaOversikt().then(setRader)} />}
</div>
</div>
))}
</>
@@ -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