diff --git a/docs/MVP.md b/docs/MVP.md index d7dc4c0..cee827d 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. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only). | +| 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). | | 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 (kund/intern/partner) i nästa fas. | diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 7b1b4af..807bc0d 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -99,4 +99,17 @@ kontroll "org A har två användare" "$ANTAL_ANV" "2" ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_J" | falt .handelser.length) kontroll "tekniker i samma org når ärendet" "$ANTAL" "2" +# 9. Organisationsöversikten: tekniker nekas; arbetsledare får status ur loggen +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_J") +kontroll "tekniker nekas översikten" "$KOD" "403" +curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"epost":"lisa@a.se","losenord":"hemligt123","namn":"Lisa","roll":"arbetsledare"}' >/dev/null +TOKEN_L=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \ + -d '{"epost":"lisa@a.se","losenord":"hemligt123"}' | falt .token) +OVERSIKT=$(curl -s "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_L") +kontroll "arbetsledaren ser organisationens ärenden" "$(echo "$OVERSIKT" | falt .arenden.length)" "1" +kontroll "översikten härleder felbeskrivning" "$(echo "$OVERSIKT" | falt '.arenden[0].felbeskrivning')" "Startar inte" +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" + echo "Integrationstest: allt grönt" diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index c49b753..1b95d30 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -220,6 +220,35 @@ export function skapaServer() { } } + // Organisationsöversikt för arbetsledare/admin: alla ärenden med + // status, deltagande tekniker och sammanfattning — härlett ur + // händelseloggen, aldrig lagrat separat. + if (req.method === "GET" && vag === "/api/oversikt") { + if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") { + return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." }); + } + const rader = await pool.query( + `select a.id, a.nummer, a.skapad, a.delningskod, a.metodik_id, + count(h.id)::int as antal_handelser, + min(h.tidpunkt) as forsta, + max(h.tidpunkt) as senaste, + coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat, + (array_agg(h.handelse->'objekt'->>'beskrivning') + 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(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 + where a.organisation_id = $1 + group by a.id + order by max(h.tidpunkt) desc nulls last + limit 200`, + [anspr.org], + ); + return svara(res, 200, { arenden: rader.rows }); + } + if (req.method === "GET" && vag === "/api/arenden") { const rader = await pool.query( `select id, nummer, skapad, delningskod, metodik_id from felsokning_arenden diff --git a/src/App.tsx b/src/App.tsx index 6690108..c2ef606 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index 85a0d29..4e8546f 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -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 { + 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 { diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index a51c6d0..19538ef 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -57,6 +57,11 @@ function PlattformInloggning() { Logga ut + {(konto.roll === "arbetsledare" || konto.roll === "admin") && ( + + 📊 Organisationsöversikt + + )} {konto.roll === "admin" && } diff --git a/src/pages/felsokning/Oversikt.tsx b/src/pages/felsokning/Oversikt.tsx new file mode 100644 index 0000000..9f5b7fe --- /dev/null +++ b/src/pages/felsokning/Oversikt.tsx @@ -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(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 ( + + +

+ Översikten kräver arbetsledar- eller administratörsbehörighet på plattformen. +

+
+
+ ); + } + + // 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 ( + {konto?.organisation}} + > + {fel &&

{fel}

} + {!rader && !fel &&

Hämtar …

} + + {rader && ( + <> + +
+
+

{rader.length}

+

Totalt

+
+
+

{pagaende.length}

+

Pågående

+
+
+

{avslutade.length}

+

Avslutade

+
+
+ {avslutade.length > 0 && ( +

+ Genomsnittlig ledtid för avslutade: {formateraTid(medelLedtidMs)} +

+ )} +
+ + {rader.length === 0 &&

Inga ärenden i organisationen ännu.

} + + {rader.map((rad) => ( +
+
+ + Ärende #{rad.nummer} + + + {rad.avslutat ? "Avslutat" : "Pågående"} + +
+

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

+ {rad.felbeskrivning &&

”{rad.felbeskrivning}”

} +

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

+ oppna(rad)}> + Öppna ärendet + +
+ ))} + + )} +
+ ); +}