Master Prompt v2.0 + röstinmatning, verifierade checklistor och export
Dokumentation: - MASTER-PROMPT.md uppdaterad till v2.0 (SaaS/AI-wrapper, roller, multi-tenant, onboarding, beta-fokus). - Nya moduler: kommunikationsmodell (tal in/text ut, Push-to-Talk, redigering före skick), Live Share (behörighetsstyrd realtidslänk, versionsmärkta exporter), verifierade checklistor (bevis och kontext per kontrolltyp). Implementation i MVP:t: - Push-to-Talk-röstinmatning via webbläsarens taligenkänning (sv-SE): lyssnar endast efter aktivt tryck, röd indikator med realtids- transkript, texten hamnar i redigerbart fält och skickas aldrig automatiskt. Döljs i webbläsare utan talstöd. - Verifierade checklistor: varje kontroll i metodikerna har minimikrav (foto/mätvärde/kommentar); fotokontroller verifieras med bild och mätkontroller kan inte markeras verifierade utan värde. - Versionsmärkt JSON-export ur händelseloggen; exporten loggas själv som händelse med format, version och användare. Verifierat: 12 vitest-tester gröna, produktionsbygge ok, utökat Playwright-röktest genom hela flödet inkl. fotokravet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
// Push-to-Talk-mikrofon: lyssnar ENDAST efter aktivt tryck, visar tydligt
|
||||
// när inspelning pågår och skriver in transkriberingen i ett redigerbart
|
||||
// fält. Skicka sker aldrig automatiskt.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { startaIgenkanning, stodRost } from "./rost";
|
||||
|
||||
export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) {
|
||||
const [lyssnar, setLyssnar] = useState(false);
|
||||
const [interim, setInterim] = useState("");
|
||||
const igenkanningRef = useRef<ReturnType<typeof startaIgenkanning>>(undefined);
|
||||
const paTextRef = useRef(paText);
|
||||
paTextRef.current = paText;
|
||||
|
||||
useEffect(() => () => igenkanningRef.current?.abort(), []);
|
||||
|
||||
if (!stodRost()) return null;
|
||||
|
||||
const stoppa = () => {
|
||||
igenkanningRef.current?.stop();
|
||||
igenkanningRef.current = undefined;
|
||||
setLyssnar(false);
|
||||
setInterim("");
|
||||
};
|
||||
|
||||
const starta = () => {
|
||||
setLyssnar(true);
|
||||
igenkanningRef.current = startaIgenkanning(
|
||||
({ transkript, slutgiltigt }) => {
|
||||
if (slutgiltigt) {
|
||||
setInterim("");
|
||||
paTextRef.current(transkript.trim());
|
||||
} else {
|
||||
setInterim(transkript);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
setLyssnar(false);
|
||||
setInterim("");
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (lyssnar ? stoppa() : starta())}
|
||||
aria-label={lyssnar ? "Stoppa inspelning" : "Starta röstinmatning"}
|
||||
className={`min-h-11 min-w-11 rounded-full border-2 px-3 text-lg font-bold transition-colors ${
|
||||
lyssnar
|
||||
? "animate-pulse border-red-500 bg-red-600 text-white"
|
||||
: "border-zinc-600 bg-zinc-800 text-zinc-100 hover:border-amber-400"
|
||||
}`}
|
||||
>
|
||||
{lyssnar ? "■" : "🎤"}
|
||||
</button>
|
||||
{lyssnar && (
|
||||
<span className="text-sm font-bold text-red-400">
|
||||
● Inspelning pågår{interim ? ` — ”${interim}”` : "…"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,19 @@ const OBJEKT: Handelse = {
|
||||
},
|
||||
};
|
||||
|
||||
describe("verifierade checklistor", () => {
|
||||
it("varje kontroll i metodikerna har ett definierat minimikrav", async () => {
|
||||
const { GENERISK_METODIK } = await import("../metodik");
|
||||
for (const metodik of [VIBRATION_METODIK, GENERISK_METODIK]) {
|
||||
for (const steg of metodik.steg) {
|
||||
for (const kontroll of steg.kontroller ?? []) {
|
||||
expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("metodikval", () => {
|
||||
it("väljer vibrationsmetodiken när felbeskrivningen nämner vibration", () => {
|
||||
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
|
||||
|
||||
@@ -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: "export_skapad"; format: string; version: number }
|
||||
| { typ: "arende_avslutat" };
|
||||
|
||||
export interface LoggPost {
|
||||
@@ -106,6 +107,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 "export_skapad":
|
||||
return `Export skapad: ${h.format}, version ${h.version}`;
|
||||
case "arende_avslutat":
|
||||
return "Felsökning avslutad";
|
||||
}
|
||||
|
||||
+28
-23
@@ -15,9 +15,14 @@ export interface Fraga {
|
||||
val?: string[];
|
||||
}
|
||||
|
||||
// Minimikrav enligt modulen Verifierade checklistor: en kontrollpunkt är
|
||||
// inte slutförd enbart genom en kryssruta — den samlar bevis och kontext.
|
||||
export type KontrollKrav = "matvarde" | "kommentar" | "foto";
|
||||
|
||||
export interface Kontroll {
|
||||
id: string;
|
||||
text: string;
|
||||
krav?: KontrollKrav;
|
||||
}
|
||||
|
||||
export interface MetodikSteg {
|
||||
@@ -67,23 +72,23 @@ export const VIBRATION_METODIK: Metodik = {
|
||||
rubrik: "Visuell kontroll",
|
||||
beskrivning: "Fotografera samtliga hjul. Skilj på observation och slutsats.",
|
||||
kontroller: [
|
||||
{ id: "foto_vf", text: "Fotografera vänster framhjul" },
|
||||
{ id: "foto_hf", text: "Fotografera höger framhjul" },
|
||||
{ id: "foto_vb", text: "Fotografera vänster bakhjul" },
|
||||
{ id: "foto_hb", text: "Fotografera höger bakhjul" },
|
||||
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum" },
|
||||
{ id: "slitage", text: "Kontrollera däckslitage" },
|
||||
{ id: "foto_vf", text: "Fotografera vänster framhjul", krav: "foto" },
|
||||
{ id: "foto_hf", text: "Fotografera höger framhjul", krav: "foto" },
|
||||
{ id: "foto_vb", text: "Fotografera vänster bakhjul", krav: "foto" },
|
||||
{ id: "foto_hb", text: "Fotografera höger bakhjul", krav: "foto" },
|
||||
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum", krav: "kommentar" },
|
||||
{ id: "slitage", text: "Kontrollera däckslitage", krav: "kommentar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kontroller",
|
||||
rubrik: "Rekommenderade kontroller",
|
||||
kontroller: [
|
||||
{ id: "lufttryck", text: "Kontrollera lufttryck" },
|
||||
{ id: "hjulmoment", text: "Kontrollera hjulmoment" },
|
||||
{ id: "kast", text: "Kontrollera radial- och sidokast" },
|
||||
{ id: "balansering", text: "Kontrollera hjulbalansering" },
|
||||
{ id: "bussningar", text: "Kontrollera bussningar och leder" },
|
||||
{ id: "lufttryck", text: "Kontrollera lufttryck", krav: "matvarde" },
|
||||
{ id: "hjulmoment", text: "Kontrollera hjulmoment", krav: "matvarde" },
|
||||
{ id: "kast", text: "Kontrollera radial- och sidokast", krav: "matvarde" },
|
||||
{ id: "balansering", text: "Kontrollera hjulbalansering", krav: "kommentar" },
|
||||
{ id: "bussningar", text: "Kontrollera bussningar och leder", krav: "kommentar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -91,11 +96,11 @@ export const VIBRATION_METODIK: Metodik = {
|
||||
rubrik: "Provkörning",
|
||||
beskrivning: "Verifiera under provkörning:",
|
||||
kontroller: [
|
||||
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår" },
|
||||
{ id: "pk_acc", text: "Förändring vid acceleration" },
|
||||
{ id: "pk_motorbroms", text: "Förändring vid motorbroms" },
|
||||
{ id: "pk_kurva", text: "Förändring vid kurvtagning" },
|
||||
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross" },
|
||||
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår", krav: "kommentar" },
|
||||
{ id: "pk_acc", text: "Förändring vid acceleration", krav: "kommentar" },
|
||||
{ id: "pk_motorbroms", text: "Förändring vid motorbroms", krav: "kommentar" },
|
||||
{ id: "pk_kurva", text: "Förändring vid kurvtagning", krav: "kommentar" },
|
||||
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross", krav: "kommentar" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -124,25 +129,25 @@ export const GENERISK_METODIK: Metodik = {
|
||||
id: "visuell",
|
||||
rubrik: "Visuell kontroll",
|
||||
kontroller: [
|
||||
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området" },
|
||||
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar" },
|
||||
{ id: "typskylt", text: "Dokumentera typskylt/märkning" },
|
||||
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området", krav: "foto" },
|
||||
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar", krav: "kommentar" },
|
||||
{ id: "typskylt", text: "Dokumentera typskylt/märkning", krav: "foto" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "grundkontroller",
|
||||
rubrik: "Grundkontroller",
|
||||
kontroller: [
|
||||
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)" },
|
||||
{ id: "sakringar", text: "Kontrollera säkringar/skydd" },
|
||||
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar" },
|
||||
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)", krav: "matvarde" },
|
||||
{ id: "sakringar", text: "Kontrollera säkringar/skydd", krav: "kommentar" },
|
||||
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar", krav: "kommentar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "funktionstest",
|
||||
rubrik: "Funktionstest",
|
||||
kontroller: [
|
||||
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet" },
|
||||
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Tal-till-text enligt kommunikationsmodellen: rösten är ett inmatningssätt,
|
||||
// inte ett separat gränssnitt. All logik bygger på text — transkriberingen
|
||||
// hamnar i ett redigerbart fält och ingenting skickas utan bekräftelse.
|
||||
//
|
||||
// MVP:t använder webbläsarens SpeechRecognition (sv-SE). I produktions-
|
||||
// versionen ersätts motorn av leverantörens Voice-to-Text-API bakom samma
|
||||
// gränssnitt.
|
||||
|
||||
interface IgenkanningsResultat {
|
||||
transkript: string;
|
||||
slutgiltigt: boolean;
|
||||
}
|
||||
|
||||
interface Igenkanning {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type SpeechRecognitionCtor = new () => {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onresult: ((event: { resultIndex: number; results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
};
|
||||
|
||||
function ctor(): SpeechRecognitionCtor | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
|
||||
}
|
||||
|
||||
export function stodRost(): boolean {
|
||||
return ctor() !== undefined;
|
||||
}
|
||||
|
||||
export function startaIgenkanning(
|
||||
paResultat: (r: IgenkanningsResultat) => void,
|
||||
paSlut: () => void,
|
||||
): Igenkanning | undefined {
|
||||
const Ctor = ctor();
|
||||
if (!Ctor) return undefined;
|
||||
const igenkanning = new Ctor();
|
||||
igenkanning.lang = "sv-SE";
|
||||
igenkanning.continuous = true;
|
||||
igenkanning.interimResults = true;
|
||||
igenkanning.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const resultat = event.results[i];
|
||||
paResultat({ transkript: resultat[0].transcript, slutgiltigt: resultat.isFinal });
|
||||
}
|
||||
};
|
||||
igenkanning.onend = paSlut;
|
||||
igenkanning.onerror = paSlut;
|
||||
igenkanning.start();
|
||||
return igenkanning;
|
||||
}
|
||||
+16
-2
@@ -3,9 +3,10 @@
|
||||
// träffytor (handskvänligt), få val per skärm, inga distraktioner.
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import type { Tillforlitlighet } from "./domain";
|
||||
import { TILLFORLITLIGHET_LABEL } from "./domain";
|
||||
import { MikrofonKnapp } from "./Mikrofon";
|
||||
|
||||
export function FelsokningSkal({
|
||||
rubrik,
|
||||
@@ -90,18 +91,31 @@ export function TextFalt({
|
||||
satt,
|
||||
platshallare,
|
||||
flerRad,
|
||||
rost,
|
||||
}: {
|
||||
label: string;
|
||||
varde: string;
|
||||
satt: (v: string) => void;
|
||||
platshallare?: string;
|
||||
flerRad?: boolean;
|
||||
rost?: boolean;
|
||||
}) {
|
||||
// Tal-till-text skriver in i samma redigerbara fält — användaren
|
||||
// granskar och bekräftar alltid innan något sparas i loggen.
|
||||
const vardeRef = useRef(varde);
|
||||
vardeRef.current = varde;
|
||||
const klass =
|
||||
"w-full rounded-lg border-2 border-zinc-600 bg-zinc-950 px-4 py-3 text-lg text-zinc-50 placeholder-zinc-500 focus:border-amber-400 focus:outline-none";
|
||||
return (
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
|
||||
<span className="mb-1 flex min-h-11 items-center justify-between gap-2">
|
||||
<span className="text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
|
||||
{rost && (
|
||||
<MikrofonKnapp
|
||||
paText={(text) => satt(vardeRef.current ? `${vardeRef.current.trimEnd()} ${text}` : text)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{flerRad ? (
|
||||
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
|
||||
) : (
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function ArendeSida() {
|
||||
)}
|
||||
{flik === "logg" && <LoggFlik arende={arende} />}
|
||||
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} />}
|
||||
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} />}
|
||||
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
|
||||
</FelsokningSkal>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function InaktivitetsBanner({
|
||||
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
|
||||
</p>
|
||||
<p className="mb-3 text-zinc-300">Beskriv kort vad som gjorts under denna period.</p>
|
||||
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." />
|
||||
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." rost />
|
||||
<StorKnapp
|
||||
disabled={!text.trim()}
|
||||
onClick={() => {
|
||||
@@ -245,7 +245,7 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
|
||||
)}
|
||||
{fraga.svarstyp === "text" && (
|
||||
<>
|
||||
<TextFalt label="Svar" varde={text} satt={setText} />
|
||||
<TextFalt label="Svar" varde={text} satt={setText} rost />
|
||||
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
|
||||
Spara svar
|
||||
</StorKnapp>
|
||||
@@ -255,11 +255,14 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
|
||||
);
|
||||
}
|
||||
|
||||
// Verifierad checklista: en kontrollpunkt är inte slutförd enbart genom en
|
||||
// kryssruta — minimikravet (foto, mätvärde eller kort observation) styrs av
|
||||
// kontrolltypen i metodiken.
|
||||
function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
|
||||
const kontroll = steg.kontroll!;
|
||||
const [resultat, setResultat] = useState("");
|
||||
const filRef = useRef<HTMLInputElement>(null);
|
||||
const arFoto = /fotografera|foto/i.test(kontroll.text);
|
||||
const krav = kontroll.krav;
|
||||
|
||||
const utford = () =>
|
||||
skicka({
|
||||
@@ -270,11 +273,13 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
|
||||
resultat: resultat.trim() || undefined,
|
||||
});
|
||||
|
||||
const kravUppfyllt = krav === "foto" || !krav || resultat.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{steg.steg.beskrivning && <p className="mb-2 text-zinc-400">{steg.steg.beskrivning}</p>}
|
||||
<p className="mb-4 text-2xl font-extrabold leading-snug">{kontroll.text}</p>
|
||||
{arFoto && (
|
||||
{krav === "foto" ? (
|
||||
<>
|
||||
<input
|
||||
ref={filRef}
|
||||
@@ -290,13 +295,36 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
|
||||
utford();
|
||||
}}
|
||||
/>
|
||||
<StorKnapp className="mb-2" onClick={() => filRef.current?.click()}>
|
||||
📷 Ta / välj foto
|
||||
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
|
||||
<StorKnapp onClick={() => filRef.current?.click()}>📷 Ta foto — verifierar kontrollen</StorKnapp>
|
||||
<p className="mt-2 text-sm text-zinc-500">Denna kontroll verifieras med foto.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextFalt
|
||||
label={
|
||||
krav === "matvarde"
|
||||
? "Uppmätt värde (krävs för verifiering)"
|
||||
: krav === "kommentar"
|
||||
? "Vad observerades? (krävs för verifiering)"
|
||||
: "Resultat / avläst värde (valfritt)"
|
||||
}
|
||||
varde={resultat}
|
||||
satt={setResultat}
|
||||
platshallare={krav === "matvarde" ? "T.ex. 2,4 bar samtliga hjul" : "T.ex. Säkringen är hel, spänning på båda sidor"}
|
||||
rost
|
||||
/>
|
||||
{!kravUppfyllt && (
|
||||
<p className="mb-3 text-sm font-bold text-amber-400">
|
||||
Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats — lägg till en kort{" "}
|
||||
{krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.
|
||||
</p>
|
||||
)}
|
||||
<StorKnapp disabled={!kravUppfyllt} onClick={utford}>
|
||||
Markera verifierad
|
||||
</StorKnapp>
|
||||
</>
|
||||
)}
|
||||
<TextFalt label="Resultat / avläst värde (valfritt)" varde={resultat} satt={setResultat} platshallare="T.ex. 2,4 bar samtliga hjul" />
|
||||
<StorKnapp onClick={utford}>Markera utförd</StorKnapp>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -375,6 +403,7 @@ function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
|
||||
varde={text}
|
||||
satt={setText}
|
||||
platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""}
|
||||
rost
|
||||
/>
|
||||
{typ === "matvarde" && <TextFalt label="Värde (med enhet)" varde={varde} satt={setVarde} platshallare="T.ex. 13,9 V" />}
|
||||
<StorKnapp disabled={!text.trim() || (typ === "matvarde" && !varde.trim())} onClick={spara}>
|
||||
@@ -521,12 +550,41 @@ function BriefFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik;
|
||||
|
||||
// Kundrapporten: en tydlig tidslinje i stället för "Felsökning – 2,5 timmar".
|
||||
// Interna poster (kategoribyten) visas inte för kund.
|
||||
function RapportFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) {
|
||||
function RapportFlik({
|
||||
arende,
|
||||
metodik,
|
||||
nu,
|
||||
skicka,
|
||||
}: {
|
||||
arende: Arende;
|
||||
metodik: Metodik;
|
||||
nu: string;
|
||||
skicka: (h: Handelse) => void;
|
||||
}) {
|
||||
const anvandare = useFelsokning((s) => s.anvandare);
|
||||
const b = brief(arende, metodik, nu);
|
||||
const bilder = foton(arende);
|
||||
const fordelning = tidsfordelningsRader(arende, nu);
|
||||
const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte");
|
||||
|
||||
// Alla exporter bygger på samma händelselogg och versionsmärks:
|
||||
// version = antal händelser vid exporttillfället.
|
||||
const exporteraJson = () => {
|
||||
const version = arende.handelser.length;
|
||||
const data = {
|
||||
export: { format: "JSON", version, exporteradAv: anvandare, tidpunkt: new Date().toISOString() },
|
||||
arende,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const lank = document.createElement("a");
|
||||
lank.href = url;
|
||||
lank.download = `arende-${arende.nummer}-v${version}.json`;
|
||||
lank.click();
|
||||
URL.revokeObjectURL(url);
|
||||
skicka({ typ: "export_skapad", format: "JSON", version });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel rubrik="Kundrapport">
|
||||
@@ -534,9 +592,14 @@ function RapportFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik
|
||||
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas — bilder kan
|
||||
innehålla uppgifter om andra kunder.
|
||||
</p>
|
||||
<StorKnapp variant="sekundar" onClick={() => window.print()}>
|
||||
Skriv ut / spara som PDF
|
||||
</StorKnapp>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => window.print()}>
|
||||
Skriv ut / PDF
|
||||
</StorKnapp>
|
||||
<StorKnapp variant="sekundar" onClick={exporteraJson}>
|
||||
Exportera JSON
|
||||
</StorKnapp>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel rubrik={`Ärende #${arende.nummer}`}>
|
||||
{b.objekt && (
|
||||
|
||||
Reference in New Issue
Block a user