Arbetsledarvyn: organisationsöversikt med status och statistik

Rollen arbetsledare får sin vy enligt Master Prompt ('se alla ärenden,
följa status, skapa statistik'):

- Ny behörighetsstyrd endpoint GET /api/oversikt (arbetsledare/admin):
  organisationens alla ärenden med status, antal händelser, deltagande
  tekniker, objekt och felbeskrivning — allt härlett ur händelseloggen
  i en SQL-fråga, aldrig lagrat separat. Tekniker nekas med 403.
- Ny sida /felsokning/oversikt: statistikpanel (totalt/pågående/
  avslutade + genomsnittlig ledtid), ärendelista med status och
  'Öppna ärendet' som hämtar händelserna till enheten — konfliktfri
  flätning om en lokal kopia redan finns. Länkas från kontopanelen
  för arbetsledare/admin.
- Integrationstestet utökat: tekniker nekas översikten, arbetsledaren
  ser organisationens ärenden och härledningarna (felbeskrivning,
  status, händelseantal) stämmer.

Ej implementerat (medvetet): omfördelning av ärenden — överlämnings-
händelsen täcker handover tills en ansvarig-modell införs.

Verifierat: integrationstestets 17 kontroller gröna mot Postgres 16,
28 vitest-tester gröna, produktionsbygge ok, Playwright-röktest grönt.

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 07:07:25 +00:00
parent b069c9a12f
commit 7b7cc855c7
7 changed files with 226 additions and 1 deletions
+2
View File
@@ -19,6 +19,7 @@ import NyttArende from "./pages/felsokning/NyttArende";
import ArendeSida from "./pages/felsokning/ArendeSida";
import DelatArende from "./pages/felsokning/DelatArende";
import PublikDelning from "./pages/felsokning/PublikDelning";
import Oversikt from "./pages/felsokning/Oversikt";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
@@ -43,6 +44,7 @@ function AppContent() {
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
<Route path="/felsokning/dela/:id" element={<DelatArende />} />
<Route path="/felsokning/delad/:kod" element={<PublikDelning />} />
<Route path="/felsokning/oversikt" element={<Oversikt />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
+22
View File
@@ -98,6 +98,28 @@ export async function skapaAnvandare(
}
}
// Organisationsöversikt (arbetsledare/admin; servern verifierar).
export interface OversiktsRad {
id: string;
nummer: number;
skapad: string;
delningskod: string | null;
metodik_id: string | null;
antal_handelser: number;
forsta: string | null;
senaste: string | null;
avslutat: boolean;
objekt: string | null;
felbeskrivning: string | null;
tekniker: string[] | null;
}
export async function hamtaOversikt(): Promise<OversiktsRad[]> {
const res = await plattformFetch("/api/oversikt");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { arenden: OversiktsRad[] }).arenden;
}
// Autentiserat anrop mot plattformen. En utgången token rensas (401) så
// att appen faller tillbaka till lokalt läge tills nästa inloggning.
export async function plattformFetch(vag: string, init?: RequestInit): Promise<Response> {
+5
View File
@@ -57,6 +57,11 @@ function PlattformInloggning() {
Logga ut
</button>
</div>
{(konto.roll === "arbetsledare" || konto.roll === "admin") && (
<Link to="/felsokning/oversikt" className="mt-3 block">
<StorKnapp variant="sekundar">📊 Organisationsöversikt</StorKnapp>
</Link>
)}
</Panel>
{konto.roll === "admin" && <AnvandarAdmin />}
</>
+154
View File
@@ -0,0 +1,154 @@
// Arbetsledarvyn: organisationens alla ärenden med status och statistik.
// Endast arbetsledare/admin (servern verifierar behörigheten). Ett ärende
// kan hämtas till den här enheten — händelserna flätas ihop konfliktfritt
// med eventuell lokal kopia.
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Arende } from "@/felsokning/domain";
import {
hamtaOversikt,
plattformAktiv,
plattformFetch,
plattformKonto,
type OversiktsRad,
} from "@/felsokning/plattform";
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";
export default function Oversikt() {
const navigate = useNavigate();
const { arenden, laggInArende, sammanfoga } = useFelsokning();
const [rader, setRader] = useState<OversiktsRad[] | null>(null);
const [fel, setFel] = useState("");
const konto = plattformKonto();
const behorig = plattformAktiv() && (konto?.roll === "arbetsledare" || konto?.roll === "admin");
useEffect(() => {
if (!behorig) return;
hamtaOversikt()
.then(setRader)
.catch(() => setFel("Kunde inte hämta översikten — kontrollera inloggningen."));
}, [behorig]);
if (!behorig) {
return (
<FelsokningSkal rubrik="Organisationsöversikt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-lg text-zinc-300">
Översikten kräver arbetsledar- eller administratörsbehörighet plattformen.
</p>
</Panel>
</FelsokningSkal>
);
}
// Hämtar ärendets händelser och materialiserar det lokalt (flätning om
// en lokal kopia redan finns), sedan öppnas arbetsytan.
const oppna = async (rad: OversiktsRad) => {
try {
const res = await plattformFetch(`/api/arenden/${rad.id}/handelser`);
if (!res.ok) throw new Error(`Fel ${res.status}`);
const { handelser } = (await res.json()) as {
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Arende["handelser"][number]["handelse"] }[];
};
const fjarr = handelser.map((h) => ({ ...h, tidpunkt: new Date(h.tidpunkt).toISOString() }));
const lokal = arenden[rad.id];
if (lokal) {
sammanfoga(rad.id, flataIhop(lokal.handelser, fjarr));
} else {
laggInArende({
id: rad.id,
nummer: rad.nummer,
skapad: new Date(rad.skapad).toISOString(),
delningskod: rad.delningskod ?? undefined,
metodikId: rad.metodik_id ?? undefined,
handelser: fjarr,
});
}
navigate(`/felsokning/arende/${rad.id}`);
} catch {
setFel("Kunde inte hämta ärendet.");
}
};
const pagaende = rader?.filter((r) => !r.avslutat) ?? [];
const avslutade = rader?.filter((r) => r.avslutat) ?? [];
const medelLedtidMs =
avslutade.length > 0
? avslutade.reduce(
(summa, r) =>
summa + (r.forsta && r.senaste ? new Date(r.senaste).getTime() - new Date(r.forsta).getTime() : 0),
0,
) / avslutade.length
: 0;
return (
<FelsokningSkal
rubrik="Organisationsöversikt"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
hoger={<span className="text-sm font-bold text-zinc-400">{konto?.organisation}</span>}
>
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
{!rader && !fel && <p className="animate-pulse text-lg text-zinc-400">Hämtar </p>}
{rader && (
<>
<Panel rubrik="Statistik">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-3xl font-extrabold">{rader.length}</p>
<p className="text-sm font-bold uppercase text-zinc-400">Totalt</p>
</div>
<div>
<p className="text-3xl font-extrabold text-amber-400">{pagaende.length}</p>
<p className="text-sm font-bold uppercase text-zinc-400">Pågående</p>
</div>
<div>
<p className="text-3xl font-extrabold">{avslutade.length}</p>
<p className="text-sm font-bold uppercase text-zinc-400">Avslutade</p>
</div>
</div>
{avslutade.length > 0 && (
<p className="mt-3 text-center text-zinc-300">
Genomsnittlig ledtid för avslutade: <span className="font-extrabold">{formateraTid(medelLedtidMs)}</span>
</p>
)}
</Panel>
{rader.length === 0 && <p className="text-center text-lg text-zinc-400">Inga ärenden i organisationen ännu.</p>}
{rader.map((rad) => (
<div key={rad.id} className="mb-3 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-extrabold uppercase tracking-widest text-zinc-400">
Ärende #{rad.nummer}
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-extrabold uppercase ${
rad.avslutat ? "bg-zinc-700 text-zinc-300" : "bg-amber-400 text-zinc-950"
}`}
>
{rad.avslutat ? "Avslutat" : "Pågående"}
</span>
</div>
<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(", ")}` : ""}
{rad.senaste ? ` · senast ${tidDatum(rad.senaste)} ${tidKlockslag(rad.senaste)}` : ""}
</p>
<StorKnapp variant="sekundar" className="mt-2" onClick={() => oppna(rad)}>
Öppna ärendet
</StorKnapp>
</div>
))}
</>
)}
</FelsokningSkal>
);
}