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 -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>
))}
</>