Backend-grund, synklager, Live Share-vy och elsystemmetodik
- Supabase-migration: felsokning_arenden + felsokning_handelser med RLS för inloggade och append-only garanterat i databasen (inga update/delete-rättigheter). Live Share förberedd via security definer-funktionen hamta_delat_arende(delningskod) så att anonyma läsare aldrig når tabellerna direkt; kategoribyten filtreras bort. Realtime-publicering aktiverad för händelsetabellen. - Synklager i klienten (synk.ts): deterministisk, konfliktfri ihopflätning av händelser per id (testad: dedupe, tidsordning, idempotens), push av lokala och pull av kollegors händelser var 15:e sekund. Utan inloggning: lokalt läge, status i ärendehuvudet. - Live Share-vy (/felsokning/dela/:id): skrivskyddad livevy med status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje och rekommenderat nästa steg. Varje ärende får slumpad delningskod vid skapande. - Ny metodik: Elsystem/strömförsörjning enligt relä-exemplet i visionen, med mätvärdeskrav på alla mätpunkter. Verifierat: 17 vitest-tester gröna, produktionsbygge ok, Playwright- röktest inkl. Live Share-vyn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
+4
-2
@@ -25,8 +25,10 @@ npm test # projektionstester
|
|||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON), men MVP:t lagrar lokalt (localStorage). Nästa steg: backend med samma händelsemodell. |
|
| 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. |
|
||||||
| Live Share | 🔶 Kräver backend — specificerad i [moduler/live-share.md](moduler/live-share.md). Kundrapporten är förberedd som samma projektion. |
|
| 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. Anonym extern åtkomst via delningskod är förberedd i databasen (security definer-funktion `hamta_delat_arende`); varje ärende får en slumpad delningskod vid skapande. Behörighetsnivåer (kund/intern/partner) i nästa fas. |
|
||||||
|
| Öppet API | 🔶 Datamodellen är API-klar (händelser som JSON i Postgres). REST/OpenAPI-lager i nästa fas. |
|
||||||
|
|
||||||
## Arkitekturprinciper i koden
|
## Arkitekturprinciper i koden
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ChangeSubscription from "./pages/ChangeSubscription";
|
|||||||
import Arendelista from "./pages/felsokning/Arendelista";
|
import Arendelista from "./pages/felsokning/Arendelista";
|
||||||
import NyttArende from "./pages/felsokning/NyttArende";
|
import NyttArende from "./pages/felsokning/NyttArende";
|
||||||
import ArendeSida from "./pages/felsokning/ArendeSida";
|
import ArendeSida from "./pages/felsokning/ArendeSida";
|
||||||
|
import DelatArende from "./pages/felsokning/DelatArende";
|
||||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -39,6 +40,7 @@ function AppContent() {
|
|||||||
<Route path="/felsokning" element={<Arendelista />} />
|
<Route path="/felsokning" element={<Arendelista />} />
|
||||||
<Route path="/felsokning/nytt" element={<NyttArende />} />
|
<Route path="/felsokning/nytt" element={<NyttArende />} />
|
||||||
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
|
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
|
||||||
|
<Route path="/felsokning/dela/:id" element={<DelatArende />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const OBJEKT: Handelse = {
|
|||||||
|
|
||||||
describe("verifierade checklistor", () => {
|
describe("verifierade checklistor", () => {
|
||||||
it("varje kontroll i metodikerna har ett definierat minimikrav", async () => {
|
it("varje kontroll i metodikerna har ett definierat minimikrav", async () => {
|
||||||
const { GENERISK_METODIK } = await import("../metodik");
|
const { METODIKER } = await import("../metodik");
|
||||||
for (const metodik of [VIBRATION_METODIK, GENERISK_METODIK]) {
|
for (const metodik of METODIKER) {
|
||||||
for (const steg of metodik.steg) {
|
for (const steg of metodik.steg) {
|
||||||
for (const kontroll of steg.kontroller ?? []) {
|
for (const kontroll of steg.kontroller ?? []) {
|
||||||
expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined();
|
expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined();
|
||||||
@@ -44,6 +44,12 @@ describe("metodikval", () => {
|
|||||||
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
|
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
|
||||||
expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
|
expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("väljer elsystemmetodiken vid elrelaterade symptom", () => {
|
||||||
|
expect(valjMetodik("Reläet klickar inte").id).toBe("elsystem");
|
||||||
|
expect(valjMetodik("Ingen spänning till bränslepumpen").id).toBe("elsystem");
|
||||||
|
expect(valjMetodik("Belysningen fungerar inte").id).toBe("elsystem");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("nastaSteg", () => {
|
describe("nastaSteg", () => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { LoggPost } from "../domain";
|
||||||
|
import { flataIhop } from "../synk";
|
||||||
|
|
||||||
|
function post(id: string, tidpunkt: string, anvandare = "Anna"): LoggPost {
|
||||||
|
return { id, tidpunkt, anvandare, handelse: { typ: "kommentar", text: id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("flataIhop", () => {
|
||||||
|
const lokala = [post("a", "2026-08-02T08:00:00.000Z"), post("c", "2026-08-02T08:10:00.000Z")];
|
||||||
|
const fjarr = [post("a", "2026-08-02T08:00:00.000Z"), post("b", "2026-08-02T08:05:00.000Z", "Johan")];
|
||||||
|
|
||||||
|
it("flätar ihop två enheters loggar utan dubbletter, i tidsordning", () => {
|
||||||
|
const resultat = flataIhop(lokala, fjarr);
|
||||||
|
expect(resultat.map((p) => p.id)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skriver aldrig över: alla poster från båda källorna finns kvar", () => {
|
||||||
|
const resultat = flataIhop(lokala, fjarr);
|
||||||
|
expect(resultat).toHaveLength(3);
|
||||||
|
expect(resultat.find((p) => p.id === "b")?.anvandare).toBe("Johan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("är idempotent och deterministisk", () => {
|
||||||
|
const enGang = flataIhop(lokala, fjarr);
|
||||||
|
const tvaGanger = flataIhop(enGang, fjarr);
|
||||||
|
expect(tvaGanger).toEqual(enGang);
|
||||||
|
expect(flataIhop(fjarr, lokala).map((p) => p.id)).toEqual(enGang.map((p) => p.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("använder id som stabil ordning vid exakt samma tidpunkt", () => {
|
||||||
|
const samtidiga = flataIhop(
|
||||||
|
[post("x2", "2026-08-02T08:00:00.000Z")],
|
||||||
|
[post("x1", "2026-08-02T08:00:00.000Z")],
|
||||||
|
);
|
||||||
|
expect(samtidiga.map((p) => p.id)).toEqual(["x1", "x2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,9 @@ export interface Arende {
|
|||||||
id: string;
|
id: string;
|
||||||
nummer: number;
|
nummer: number;
|
||||||
skapad: string; // ISO 8601
|
skapad: string; // ISO 8601
|
||||||
|
// Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig
|
||||||
|
// ändras (ärenderaden är append-only även i databasen).
|
||||||
|
delningskod?: string;
|
||||||
handelser: LoggPost[];
|
handelser: LoggPost[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,64 @@ export const GENERISK_METODIK: Metodik = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const METODIKER = [VIBRATION_METODIK, GENERISK_METODIK];
|
// Elsystem-metodik enligt visionens relä-exempel: mätningar med krav på
|
||||||
|
// värden, reläkontroll och funktionstest.
|
||||||
|
export const ELSYSTEM_METODIK: Metodik = {
|
||||||
|
id: "elsystem",
|
||||||
|
namn: "Elsystem / strömförsörjning",
|
||||||
|
steg: [
|
||||||
|
{
|
||||||
|
id: "symptom",
|
||||||
|
rubrik: "Verifiera symptom",
|
||||||
|
fragor: [
|
||||||
|
{ id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
|
||||||
|
{
|
||||||
|
id: "omfattning",
|
||||||
|
text: "Är funktionen helt död eller delvis fungerande?",
|
||||||
|
svarstyp: "val",
|
||||||
|
val: ["Helt död", "Delvis fungerande", "Intermittent"],
|
||||||
|
},
|
||||||
|
{ id: "funktion", text: "Vilken funktion saknas eller felar?", svarstyp: "text" },
|
||||||
|
{ id: "forandring", text: "Har något åtgärdats eller förändrats nyligen?", svarstyp: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "visuell",
|
||||||
|
rubrik: "Visuell kontroll",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "foto_komponent", text: "Fotografera komponenten och dess anslutningar", krav: "foto" },
|
||||||
|
{ id: "skador", text: "Kontrollera synliga skador, korrosion och lösa kontaktdon", krav: "kommentar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "matningar",
|
||||||
|
rubrik: "Mätningar",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "batterispanning", text: "Mät batterispänning", krav: "matvarde" },
|
||||||
|
{ id: "sakringar", text: "Kontrollera berörda säkringar", krav: "kommentar" },
|
||||||
|
{ id: "matningsspanning", text: "Mät matningsspänning vid komponenten", krav: "matvarde" },
|
||||||
|
{ id: "jord", text: "Mät spänningsfall mot jord", krav: "matvarde" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rela",
|
||||||
|
rubrik: "Relä och styrsignal",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "rela_klick", text: "Kontrollera om reläet klickar vid aktivering", krav: "kommentar" },
|
||||||
|
{ id: "styrsignal", text: "Mät styrsignal till reläet", krav: "matvarde" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "funktionstest",
|
||||||
|
rubrik: "Funktionstest",
|
||||||
|
kontroller: [
|
||||||
|
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const METODIKER = [VIBRATION_METODIK, ELSYSTEM_METODIK, GENERISK_METODIK];
|
||||||
|
|
||||||
export function metodikForId(id: string): Metodik {
|
export function metodikForId(id: string): Metodik {
|
||||||
return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
|
return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
|
||||||
@@ -162,6 +219,9 @@ export function metodikForId(id: string): Metodik {
|
|||||||
export function valjMetodik(felbeskrivning: string): Metodik {
|
export function valjMetodik(felbeskrivning: string): Metodik {
|
||||||
const text = felbeskrivning.toLowerCase();
|
const text = felbeskrivning.toLowerCase();
|
||||||
if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
|
if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
|
||||||
|
if (/relä|rela\b|säkring|elektr|spänning|volt\b|batteri|lampa|belysning|ström|kortslut/.test(text)) {
|
||||||
|
return ELSYSTEM_METODIK;
|
||||||
|
}
|
||||||
return GENERISK_METODIK;
|
return GENERISK_METODIK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { Arende, Handelse, Objekt } from "./domain";
|
import type { Arende, Handelse, LoggPost, Objekt } from "./domain";
|
||||||
import { nyLoggPost } from "./domain";
|
import { nyLoggPost } from "./domain";
|
||||||
import { felbeskrivning } from "./projektioner";
|
import { felbeskrivning } from "./projektioner";
|
||||||
import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
|
import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
|
||||||
@@ -17,6 +17,15 @@ interface FelsokningState {
|
|||||||
sattAnvandare: (namn: string) => void;
|
sattAnvandare: (namn: string) => void;
|
||||||
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
|
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
|
||||||
laggTill: (arendeId: string, handelse: Handelse) => void;
|
laggTill: (arendeId: string, handelse: Handelse) => void;
|
||||||
|
// Används endast av synken: ersätter listan med den ihopflätade versionen.
|
||||||
|
// Semantiken är fortfarande append-only — flätningen lägger bara till.
|
||||||
|
sammanfoga: (arendeId: string, handelser: LoggPost[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nyDelningskod(): string {
|
||||||
|
return Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) =>
|
||||||
|
"abcdefghijklmnopqrstuvwxyz0123456789"[b % 36],
|
||||||
|
).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFelsokning = create<FelsokningState>()(
|
export const useFelsokning = create<FelsokningState>()(
|
||||||
@@ -35,6 +44,7 @@ export const useFelsokning = create<FelsokningState>()(
|
|||||||
id,
|
id,
|
||||||
nummer: nastaNummer,
|
nummer: nastaNummer,
|
||||||
skapad: new Date().toISOString(),
|
skapad: new Date().toISOString(),
|
||||||
|
delningskod: nyDelningskod(),
|
||||||
handelser: [
|
handelser: [
|
||||||
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
|
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
|
||||||
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
|
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
|
||||||
@@ -63,6 +73,16 @@ export const useFelsokning = create<FelsokningState>()(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sammanfoga: (arendeId, handelser) => {
|
||||||
|
set((s) => {
|
||||||
|
const arende = s.arenden[arendeId];
|
||||||
|
if (!arende || handelser.length === arende.handelser.length) return s;
|
||||||
|
return {
|
||||||
|
arenden: { ...s.arenden, [arendeId]: { ...arende, handelser } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ name: "guidad-felsokning" },
|
{ name: "guidad-felsokning" },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Synkronisering mot backend. Append-only-modellen gör den konfliktfri:
|
||||||
|
// händelser flätas ihop per id och ingenting skrivs över — enheter kan
|
||||||
|
// arbeta offline och synka i efterhand utan att någon post går förlorad.
|
||||||
|
//
|
||||||
|
// Kräver inloggning (Supabase Auth). Utan session arbetar appen vidare
|
||||||
|
// i lokalt läge; synk återupptas när användaren loggat in.
|
||||||
|
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Arende, Handelse, LoggPost } from "./domain";
|
||||||
|
|
||||||
|
// Deterministisk ihopflätning: dedupe per id, ordning per tidpunkt (id som
|
||||||
|
// stabil sekundärnyckel). Samma indata ger alltid samma resultat.
|
||||||
|
export function flataIhop(lokala: LoggPost[], fjarr: LoggPost[]): LoggPost[] {
|
||||||
|
const perId = new Map<string, LoggPost>();
|
||||||
|
for (const post of [...lokala, ...fjarr]) {
|
||||||
|
if (!perId.has(post.id)) perId.set(post.id, post);
|
||||||
|
}
|
||||||
|
return [...perId.values()].sort(
|
||||||
|
(a, b) => a.tidpunkt.localeCompare(b.tidpunkt) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SynkStatus = "lokal" | "synkar" | "synkad" | "offline";
|
||||||
|
|
||||||
|
export interface SynkResultat {
|
||||||
|
status: SynkStatus;
|
||||||
|
handelser?: LoggPost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FjarrHandelseRad {
|
||||||
|
id: string;
|
||||||
|
tidpunkt: string;
|
||||||
|
anvandare: string;
|
||||||
|
handelse: Handelse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function klient(): Promise<SupabaseClient | undefined> {
|
||||||
|
try {
|
||||||
|
const { supabase } = await import("@/integrations/supabase/client");
|
||||||
|
// Tabellerna är nya — den genererade Database-typen regenereras av
|
||||||
|
// Lovable/Supabase CLI; tills dess används klienten otypad här.
|
||||||
|
return supabase as unknown as SupabaseClient;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synkroniseraArende(arende: Arende): Promise<SynkResultat> {
|
||||||
|
const supabase = await klient();
|
||||||
|
if (!supabase) return { status: "lokal" };
|
||||||
|
try {
|
||||||
|
const { data } = await supabase.auth.getSession();
|
||||||
|
if (!data.session) return { status: "lokal" };
|
||||||
|
|
||||||
|
const { error: arendeFel } = await supabase.from("felsokning_arenden").upsert(
|
||||||
|
{
|
||||||
|
id: arende.id,
|
||||||
|
nummer: arende.nummer,
|
||||||
|
skapad: arende.skapad,
|
||||||
|
delningskod: arende.delningskod ?? null,
|
||||||
|
},
|
||||||
|
{ onConflict: "id", ignoreDuplicates: true },
|
||||||
|
);
|
||||||
|
if (arendeFel) throw arendeFel;
|
||||||
|
|
||||||
|
const { data: fjarrRader, error: lasFel } = await supabase
|
||||||
|
.from("felsokning_handelser")
|
||||||
|
.select("id,tidpunkt,anvandare,handelse")
|
||||||
|
.eq("arende_id", arende.id);
|
||||||
|
if (lasFel) throw lasFel;
|
||||||
|
|
||||||
|
const fjarr: LoggPost[] = ((fjarrRader ?? []) as FjarrHandelseRad[]).map((rad) => ({
|
||||||
|
id: rad.id,
|
||||||
|
tidpunkt: new Date(rad.tidpunkt).toISOString(),
|
||||||
|
anvandare: rad.anvandare,
|
||||||
|
handelse: rad.handelse,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fjarrIds = new Set(fjarr.map((p) => p.id));
|
||||||
|
const nya = arende.handelser
|
||||||
|
.filter((p) => !fjarrIds.has(p.id))
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
arende_id: arende.id,
|
||||||
|
tidpunkt: p.tidpunkt,
|
||||||
|
anvandare: p.anvandare,
|
||||||
|
handelse: p.handelse,
|
||||||
|
}));
|
||||||
|
if (nya.length > 0) {
|
||||||
|
const { error: skrivFel } = await supabase.from("felsokning_handelser").insert(nya);
|
||||||
|
if (skrivFel) throw skrivFel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) };
|
||||||
|
} catch {
|
||||||
|
return { status: "offline" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
tidsfordelningsRader,
|
tidsfordelningsRader,
|
||||||
} from "@/felsokning/projektioner";
|
} from "@/felsokning/projektioner";
|
||||||
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
||||||
|
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
|
||||||
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
||||||
|
|
||||||
@@ -29,12 +30,50 @@ const FLIKAR = [
|
|||||||
|
|
||||||
type Flik = (typeof FLIKAR)[number]["id"];
|
type Flik = (typeof FLIKAR)[number]["id"];
|
||||||
|
|
||||||
|
const SYNKSTATUS_LABEL: Record<SynkStatus, string> = {
|
||||||
|
lokal: "Lokalt läge",
|
||||||
|
synkar: "Synkar …",
|
||||||
|
synkad: "Synkad",
|
||||||
|
offline: "Offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Synkar ärendet mot backend: direkt vid nya händelser och därefter var
|
||||||
|
// 15:e sekund (hämtar kollegors händelser). Utan inloggning: lokalt läge.
|
||||||
|
function useSynk(arende: Arende | undefined): SynkStatus {
|
||||||
|
const [status, setStatus] = useState<SynkStatus>("lokal");
|
||||||
|
const sammanfoga = useFelsokning((s) => s.sammanfoga);
|
||||||
|
const arendeId = arende?.id;
|
||||||
|
const antal = arende?.handelser.length ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arende || !arendeId) return;
|
||||||
|
let aktiv = true;
|
||||||
|
const kor = async () => {
|
||||||
|
setStatus((s) => (s === "synkad" ? "synkar" : s));
|
||||||
|
const resultat = await synkroniseraArende(arende);
|
||||||
|
if (!aktiv) return;
|
||||||
|
setStatus(resultat.status);
|
||||||
|
if (resultat.handelser) sammanfoga(arendeId, resultat.handelser);
|
||||||
|
};
|
||||||
|
kor();
|
||||||
|
const timer = setInterval(kor, 15000);
|
||||||
|
return () => {
|
||||||
|
aktiv = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- antal driver omsynk vid nya händelser
|
||||||
|
}, [arendeId, antal, sammanfoga]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ArendeSida() {
|
export default function ArendeSida() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
|
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
|
||||||
const laggTill = useFelsokning((s) => s.laggTill);
|
const laggTill = useFelsokning((s) => s.laggTill);
|
||||||
const [flik, setFlik] = useState<Flik>("guide");
|
const [flik, setFlik] = useState<Flik>("guide");
|
||||||
const [nu, setNu] = useState(() => new Date().toISOString());
|
const [nu, setNu] = useState(() => new Date().toISOString());
|
||||||
|
const synkStatus = useSynk(arende);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
|
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
|
||||||
@@ -63,7 +102,9 @@ export default function ArendeSida() {
|
|||||||
hoger={
|
hoger={
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-lg font-extrabold text-amber-400">{total}</p>
|
<p className="text-lg font-extrabold text-amber-400">{total}</p>
|
||||||
<p className="text-xs font-bold uppercase text-zinc-500">{avslutat ? "Avslutat" : "Pågår"}</p>
|
<p className="text-xs font-bold uppercase text-zinc-500">
|
||||||
|
{avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -600,6 +641,9 @@ function RapportFlik({
|
|||||||
Exportera JSON
|
Exportera JSON
|
||||||
</StorKnapp>
|
</StorKnapp>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
|
||||||
|
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
|
||||||
|
</Link>
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel rubrik={`Ärende #${arende.nummer}`}>
|
<Panel rubrik={`Ärende #${arende.nummer}`}>
|
||||||
{b.objekt && (
|
{b.objekt && (
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Live Share: skrivskyddad livevy av ett ärende — det kunden, arbetsledaren
|
||||||
|
// eller en extern partner ser via delningslänken. Uppdateras automatiskt
|
||||||
|
// när ny information registreras. Interna poster (kategoribyten) visas inte.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { handelseRubrik } from "@/felsokning/domain";
|
||||||
|
import { arAvslutat, brief, foton, tidsfordelningsRader } from "@/felsokning/projektioner";
|
||||||
|
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
||||||
|
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||||
|
import { FelsokningSkal, Panel } from "@/felsokning/ui";
|
||||||
|
|
||||||
|
export default function DelatArende() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
|
||||||
|
const [nu, setNu] = useState(() => new Date().toISOString());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!arende) {
|
||||||
|
return (
|
||||||
|
<FelsokningSkal rubrik="Delat ärende">
|
||||||
|
<p className="text-lg text-zinc-300">Ärendet är inte tillgängligt.</p>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metodik = metodikForArende(arende);
|
||||||
|
const b = brief(arende, metodik, nu);
|
||||||
|
const avslutat = arAvslutat(arende);
|
||||||
|
const bilder = foton(arende);
|
||||||
|
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
||||||
|
const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte");
|
||||||
|
const pagaende = b.rekommenderatNastaSteg[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FelsokningSkal
|
||||||
|
rubrik={b.objekt?.beskrivning ?? "Ärende"}
|
||||||
|
hoger={
|
||||||
|
<span
|
||||||
|
className={`rounded px-3 py-1 text-sm font-extrabold uppercase ${
|
||||||
|
avslutat ? "bg-zinc-700 text-zinc-200" : "bg-green-500 text-zinc-950"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{avslutat ? "Avslutat" : "🟢 Felsökning pågår"}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-3 text-sm text-zinc-400">
|
||||||
|
Skrivskyddad livevy — uppdateras automatiskt när verkstaden registrerar ny information. Extern delning
|
||||||
|
via säker länk aktiveras när backend är i drift; detta är samma vy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{b.felbeskrivning && (
|
||||||
|
<Panel rubrik="Kundens felbeskrivning">
|
||||||
|
<p className="text-lg">”{b.felbeskrivning}”</p>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel rubrik="Aktuell status">
|
||||||
|
{b.utfordaKontroller.map((k, i) => (
|
||||||
|
<p key={`u${i}`} className="py-0.5 text-lg">✔ {k.text}</p>
|
||||||
|
))}
|
||||||
|
{!avslutat && pagaende && <p className="py-0.5 text-lg text-amber-400">🔄 {pagaende}</p>}
|
||||||
|
{b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => (
|
||||||
|
<p key={`e${i}`} className="py-0.5 text-lg text-zinc-400">⏳ {e}</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{bilder.length > 0 && (
|
||||||
|
<Panel rubrik="Bilder">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{bilder.map((bild, i) => (
|
||||||
|
<figure key={i}>
|
||||||
|
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-zinc-700" />
|
||||||
|
<figcaption className="mt-1 text-xs text-zinc-400">{bild.beskrivning}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matvarden.length > 0 && (
|
||||||
|
<Panel rubrik="Mätvärden">
|
||||||
|
<table className="w-full text-lg">
|
||||||
|
<tbody>
|
||||||
|
{matvarden.map((p) => {
|
||||||
|
const h = p.handelse;
|
||||||
|
if (h.typ !== "matvarde") return null;
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="border-b border-zinc-800 last:border-0">
|
||||||
|
<td className="py-1 pr-3 text-zinc-300">{h.beskrivning}</td>
|
||||||
|
<td className="py-1 font-bold">
|
||||||
|
{h.varde}
|
||||||
|
{h.enhet ? ` ${h.enhet}` : ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel rubrik="Tidslinje">
|
||||||
|
{kundposter.map((post) => (
|
||||||
|
<p key={post.id} className="py-0.5 text-base">
|
||||||
|
<span className="font-mono font-bold text-amber-400">{tidKlockslag(post.tidpunkt)}</span>{" "}
|
||||||
|
{handelseRubrik(post)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{!avslutat && pagaende && (
|
||||||
|
<Panel rubrik="Rekommenderat nästa steg">
|
||||||
|
<p className="text-lg">{pagaende}</p>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel rubrik="Arbetstid">
|
||||||
|
<p className="text-2xl font-extrabold">{b.totalArbetstid}</p>
|
||||||
|
{tidsfordelningsRader(arende, nu).map((r) => (
|
||||||
|
<p key={r.label} className="text-zinc-300">
|
||||||
|
{r.label}: {r.tid}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-zinc-600">
|
||||||
|
Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg
|
||||||
|
</p>
|
||||||
|
</FelsokningSkal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Guidad Felsökning: ärenden och append-only händelselogg.
|
||||||
|
-- Loggen är enda sanningskällan; alla vyer är projektioner.
|
||||||
|
-- Append-only garanteras även i databasen: inga update/delete-policyer.
|
||||||
|
|
||||||
|
create table public.felsokning_arenden (
|
||||||
|
id text primary key,
|
||||||
|
nummer integer not null,
|
||||||
|
skapad timestamptz not null,
|
||||||
|
skapad_av uuid not null default auth.uid(),
|
||||||
|
delningskod text unique,
|
||||||
|
insatt timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.felsokning_handelser (
|
||||||
|
id text primary key,
|
||||||
|
arende_id text not null references public.felsokning_arenden(id),
|
||||||
|
tidpunkt timestamptz not null,
|
||||||
|
anvandare text not null,
|
||||||
|
handelse jsonb not null,
|
||||||
|
insatt timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index felsokning_handelser_arende_idx
|
||||||
|
on public.felsokning_handelser (arende_id, tidpunkt);
|
||||||
|
|
||||||
|
alter table public.felsokning_arenden enable row level security;
|
||||||
|
alter table public.felsokning_handelser enable row level security;
|
||||||
|
|
||||||
|
-- Beta: alla inloggade användare delar arbetsyta (samarbete i ärenden).
|
||||||
|
-- Multi-tenant-uppdelning per organisation införs i nästa fas.
|
||||||
|
create policy "Inloggade läser ärenden"
|
||||||
|
on public.felsokning_arenden for select to authenticated using (true);
|
||||||
|
create policy "Inloggade skapar ärenden"
|
||||||
|
on public.felsokning_arenden for insert to authenticated with check (true);
|
||||||
|
create policy "Inloggade läser händelser"
|
||||||
|
on public.felsokning_handelser for select to authenticated using (true);
|
||||||
|
create policy "Inloggade lägger till händelser"
|
||||||
|
on public.felsokning_handelser for insert to authenticated with check (true);
|
||||||
|
|
||||||
|
-- Inga update/delete-policyer, och rättigheterna återkallas dessutom explicit:
|
||||||
|
-- historiken kan aldrig ändras eller raderas från klienten.
|
||||||
|
revoke update, delete on public.felsokning_arenden from authenticated, anon;
|
||||||
|
revoke update, delete on public.felsokning_handelser from authenticated, anon;
|
||||||
|
|
||||||
|
-- Live Share: anonym läsning sker ENDAST via delningskod genom en
|
||||||
|
-- security definer-funktion — tabellerna exponeras aldrig direkt för anon.
|
||||||
|
-- Interna poster (kategoribyten) filtreras bort ur den delade vyn.
|
||||||
|
create or replace function public.hamta_delat_arende(kod text)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'arende', to_jsonb(a) - 'skapad_av',
|
||||||
|
'handelser', coalesce((
|
||||||
|
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' <> 'kategori_byte'
|
||||||
|
), '[]'::jsonb)
|
||||||
|
)
|
||||||
|
from felsokning_arenden a
|
||||||
|
where a.delningskod = kod and a.delningskod is not null;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.hamta_delat_arende(text) to anon, authenticated;
|
||||||
|
|
||||||
|
-- Realtime-uppdateringar för framtida prenumerationer i klienten.
|
||||||
|
alter publication supabase_realtime add table public.felsokning_handelser;
|
||||||
Reference in New Issue
Block a user