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:
@@ -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 (
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface OversiktsRad {
|
||||
avslutat: boolean;
|
||||
objekt: string | null;
|
||||
felbeskrivning: string | null;
|
||||
ansvarig: string | null;
|
||||
skapare: string | null;
|
||||
tekniker: string[] | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user