Videoevidens (E3) och signerat avslut

Video med ljud är nu en riktig evidenstyp — den sista luckan i
evidensskalan E0–E6:

- Ny händelse video (beskrivning + dataUrl): Video-knappen i
  Dokumentera-panelen spelar in/väljer ett kort klipp via kameran,
  beskrivningen är obligatorisk ("det som låter eller rör sig") och
  hård storleksgräns avvisar för långa klipp med tydligt besked
- Evidence Engine: video katalogiseras som E3 och räknas som oberoende
  källa mot E6; underlagskällan "Video" i felorsaksanalysen valideras
  nu mot loggen precis som foto/mätresultat
- Originalfilen bevaras och visas i arbetsloggen, kundrapporten och
  Live Share-vyn

Signerat avslut (ur Completion-checklistan): "Avsluta felsökning"
loggar teknikerns signatur — "Felsökning avslutad — signerad av …" —
och kvalitetsgrinden redovisar signaturen.

61 vitest-tester, integrationstest, OpenAPI-validering och
preview-klicktest (beskrivningskrav, storleksgräns, videospelare i
loggen, signerat avslut) gröna.

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 10:05:41 +00:00
parent 09d3558e0c
commit 786e2159f5
10 changed files with 174 additions and 13 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| AI-guidad felsökning | ✅ Deterministisk metodikmotor (en fråga i taget, tre metodiker) **plus Claude-orkestern driven av plattformen**: edge-funktionen `felsokning-ai` äger Claude API-nyckeln (serverhemligheten `ANTHROPIC_API_KEY`) och routar per uppgift — handledning i realtid (Sonnet 5), djupgranskning av hela underlaget via knapp i briefen (Opus 5, hög effort), AI-komplettering av överlämningen med risker & osäkerheter (Sonnet 5) och metodikklassificering av felbeskrivningen (Haiku 4.5). Alla svar är schema-bundna och klassificerade enligt AI-reglerna, med automatisk fallback till Anthropics rekommenderade reservmodell vid avböjd förfrågan; modellen som svarade loggas i varje händelse. Kräver inloggad användare; svaren är interna och delas aldrig i kundvyer. I lokalt läge guidar metodiken ensam. | | AI-guidad felsökning | ✅ Deterministisk metodikmotor (en fråga i taget, tre metodiker) **plus Claude-orkestern driven av plattformen**: edge-funktionen `felsokning-ai` äger Claude API-nyckeln (serverhemligheten `ANTHROPIC_API_KEY`) och routar per uppgift — handledning i realtid (Sonnet 5), djupgranskning av hela underlaget via knapp i briefen (Opus 5, hög effort), AI-komplettering av överlämningen med risker & osäkerheter (Sonnet 5) och metodikklassificering av felbeskrivningen (Haiku 4.5). Alla svar är schema-bundna och klassificerade enligt AI-reglerna, med automatisk fallback till Anthropics rekommenderade reservmodell vid avböjd förfrågan; modellen som svarade loggas i varje händelse. Kräver inloggad användare; svaren är interna och delas aldrig i kundvyer. I lokalt läge guidar metodiken ensam. |
| Arbetslogg | ✅ Append-only händelselogg med tidsstämpel och användare på varje post. Ingenting skrivs över. | | Arbetslogg | ✅ Append-only händelselogg med tidsstämpel och användare på varje post. Ingenting skrivs över. |
| Tidredovisning | ✅ Kategorier (aktiv felsökning, väntetid, provkörning …) via kategoribyten i loggen; paus räknas inte i total tid. Inaktivitetsfråga efter 20 min utan händelser. | | Tidredovisning | ✅ Kategorier (aktiv felsökning, väntetid, provkörning …) via kategoribyten i loggen; paus räknas inte i total tid. Inaktivitetsfråga efter 20 min utan händelser. |
| Dokumentation | ✅ Observationer, mätvärden, foton (nedskalade), kommentarer och hypoteser. Hypoteser märks alltid 🔴 och kan aldrig loggas som konstaterade fel. | | Dokumentation | ✅ Observationer, mätvärden, foton (nedskalade), **video med ljud** (E3-evidens för det som låter eller rör sig — kort klipp med obligatorisk beskrivning, hård storleksgräns, originalfilen bevaras och visas i logg, rapport och Live Share), kommentarer och hypoteser. Hypoteser märks alltid som ej verifierade och kan aldrig loggas som konstaterade fel. **Avslutet signeras automatiskt** av teknikern (”Felsökning avslutad — signerad av …”), redovisat i kvalitetsgrinden. |
| Ärendebrief | ✅ Regenereras ur loggen vid varje visning: utförda kontroller, observationer, **ej kontrollerat**, rekommenderat nästa steg, tillförlitlighet, total arbetstid. | | Ärendebrief | ✅ Regenereras ur loggen vid varje visning: utförda kontroller, observationer, **ej kontrollerat**, rekommenderat nästa steg, tillförlitlighet, total arbetstid. |
| Överlämning | ✅ ”Lämna över arbete” genererar överlämningsrapport ur briefen och loggar överlämningen. | | Överlämning | ✅ ”Lämna över arbete” genererar överlämningsrapport ur briefen och loggar överlämningen. |
| Kundrapport | ✅ Tidslinjevy utan interna poster, med bilder och tidsfördelning. Utskrift/PDF via webbläsaren, med påminnelse om granskning före delning. | | Kundrapport | ✅ Tidslinjevy utan interna poster, med bilder och tidsfördelning. Utskrift/PDF via webbläsaren, med påminnelse om granskning före delning. |
+2 -1
View File
@@ -23,7 +23,7 @@ omöjligt.
| E0 | Inget underlag | 0 % | | E0 | Inget underlag | 0 % |
| E1 | Teknikerns observation | Lågt | | E1 | Teknikerns observation | Lågt |
| E2 | Foto | Medel | | E2 | Foto | Medel |
| E3 | Video | Högt | | E3 | Video (med ljud — för det som låter eller rör sig) | Högt |
| E4 | Mätvärde | Högt | | E4 | Mätvärde | Högt |
| E5 | Diagnosdata/dokument | Mycket högt | | E5 | Diagnosdata/dokument | Mycket högt |
| E6 | Flera oberoende källor | Högsta | | E6 | Flera oberoende källor | Högsta |
@@ -80,6 +80,7 @@ alla obligatoriska rader är gröna:
| Metodikens kontroller: evidens eller dokumenterat undantag | Obligatorisk | | Metodikens kontroller: evidens eller dokumenterat undantag | Obligatorisk |
| Foton för fotokrävande kontroller | Obligatorisk | | Foton för fotokrävande kontroller | Obligatorisk |
| Ärendetypens compliance-krav | Obligatoriska | | Ärendetypens compliance-krav | Obligatoriska |
| Teknikerns slutsats signerad | Automatisk vid avslut |
| Evidensnivå över E0 | Obligatorisk | | Evidensnivå över E0 | Obligatorisk |
### 6. Traceability Engine ### 6. Traceability Engine
+1
View File
@@ -653,6 +653,7 @@ components:
- arendetyp_satt - arendetyp_satt
- historik_kontrollerad - historik_kontrollerad
- matarstallning - matarstallning
- video
- reproducering - reproducering
- felorsak - felorsak
- export_skapad - export_skapad
+12 -1
View File
@@ -6,7 +6,7 @@
import type { Arende } from "./domain"; import type { Arende } from "./domain";
import { handelseRubrik } from "./domain"; import { handelseRubrik } from "./domain";
import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader } from "./projektioner"; import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader, videor } from "./projektioner";
import { metodikForArende } from "./store"; import { metodikForArende } from "./store";
import { tidDatum, tidKlockslag } from "./format"; import { tidDatum, tidKlockslag } from "./format";
import { FelsokningSkal, Panel } from "./ui"; import { FelsokningSkal, Panel } from "./ui";
@@ -58,6 +58,7 @@ export function DelatArendeVy({
const b = brief(arende, metodik, nu); const b = brief(arende, metodik, nu);
const avslutat = arAvslutat(arende); const avslutat = arAvslutat(arende);
const bilder = foton(arende); const bilder = foton(arende);
const klipp = videor(arende);
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
const kundposter = redanFiltrerad const kundposter = redanFiltrerad
? arende.handelser ? arende.handelser
@@ -102,6 +103,16 @@ export function DelatArendeVy({
))} ))}
</Panel> </Panel>
{klipp.length > 0 && (
<Panel rubrik="Video">
{klipp.map((v, i) => (
<figure key={i} className="mb-2">
<video src={v.dataUrl} controls className="w-full rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
</figure>
))}
</Panel>
)}
{bilder.length > 0 && ( {bilder.length > 0 && (
<Panel rubrik="Bilder"> <Panel rubrik="Bilder">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
+28
View File
@@ -335,3 +335,31 @@ describe("Fordonshistorik (lokal projektion)", () => {
expect(lokalFordonshistorik({ [tidigare.id]: tidigare }, "ZZZ111")).toHaveLength(0); expect(lokalFordonshistorik({ [tidigare.id]: tidigare }, "ZZZ111")).toHaveLength(0);
}); });
}); });
describe("Videoevidens (E3) och signerat avslut", () => {
it("video höjer evidensnivån till E3 och räknas som oberoende källa", () => {
const medVideo = byggArende([{ typ: "video", beskrivning: "Motorljud vid tomgång", dataUrl: "data:video/webm;base64,x" }]);
expect(evidensNiva(medVideo)).toBe("E3");
expect(evidensposter(medVideo)[0].kategori).toBe("video");
const tvaKallor = byggArende([
{ typ: "video", beskrivning: "Motorljud", dataUrl: "data:video/webm;base64,x" },
{ typ: "matvarde", beskrivning: "Oljetryck", varde: "3,1", enhet: "bar" },
]);
expect(evidensNiva(tvaKallor)).toBe("E6");
});
it("underlagskällan Video valideras mot loggen i felorsaksanalysen", async () => {
const { underlagFinns } = await import("../ecm");
expect(underlagFinns(byggArende([OBJEKT]), "Video")).toBe(false);
expect(underlagFinns(byggArende([{ typ: "video", beskrivning: "x", dataUrl: "data:" }]), "Video")).toBe(true);
});
it("avslutet signeras och grinden redovisar signaturen", async () => {
const { handelseRubrik, nyLoggPost: ny } = await import("../domain");
expect(handelseRubrik(ny("Erik", { typ: "arende_avslutat", signatur: "Erik" }))).toContain("signerad av Erik");
const stangd = byggArende([OBJEKT, ...PREDIAG, { typ: "arende_avslutat", signatur: "Erik" }]);
const rad = kvalitetsgrind(stangd, VIBRATION_METODIK).find((r) => r.id === "signering");
expect(rad?.ok).toBe(true);
expect(rad?.detalj).toContain("Erik");
});
});
+9 -2
View File
@@ -67,6 +67,10 @@ export type Handelse =
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string } | { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> } | { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
| { typ: "foto"; beskrivning: string; dataUrl: string } | { typ: "foto"; beskrivning: string; dataUrl: string }
// Video med ljud (E3-evidens): för det som låter eller rör sig —
// motorljud, hjullager, fjädringsrörelse. Kort och komprimerad;
// originalfilen bevaras som evidens precis som foton.
| { typ: "video"; beskrivning: string; dataUrl: string }
| { typ: "kommentar"; text: string } | { typ: "kommentar"; text: string }
| { typ: "kategori_byte"; kategori: TidKategori } | { typ: "kategori_byte"; kategori: TidKategori }
| { typ: "inaktivitet_forklarad"; text: string; minuter: number } | { typ: "inaktivitet_forklarad"; text: string; minuter: number }
@@ -104,7 +108,8 @@ export type Handelse =
nastaSteg: string; nastaSteg: string;
modell: string; modell: string;
} }
| { typ: "arende_avslutat" }; // Avslutet signeras av teknikern — slutsatsen får en ansvarig avsändare.
| { typ: "arende_avslutat"; signatur?: string };
export interface LoggPost { export interface LoggPost {
id: string; id: string;
@@ -160,6 +165,8 @@ export function handelseRubrik(post: LoggPost): string {
return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`; return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`;
case "foto": case "foto":
return `Foto: ${h.beskrivning}`; return `Foto: ${h.beskrivning}`;
case "video":
return `Video: ${h.beskrivning}`;
case "kommentar": case "kommentar":
return h.text; return h.text;
case "kategori_byte": case "kategori_byte":
@@ -194,6 +201,6 @@ export function handelseRubrik(post: LoggPost): string {
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`; return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
} }
case "arende_avslutat": case "arende_avslutat":
return "Felsökning avslutad"; return h.signatur ? `Felsökning avslutad — signerad av ${h.signatur}` : "Felsökning avslutad";
} }
} }
+20 -2
View File
@@ -77,6 +77,7 @@ export function evidensposter(arende: Arende): Evidenspost[] {
for (const post of arende.handelser) { for (const post of arende.handelser) {
const h = post.handelse; const h = post.handelse;
if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning); if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning);
if (h.typ === "video") lagg(post, "video", "E3", h.beskrivning);
if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`); if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`); if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`);
if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`); if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`);
@@ -90,10 +91,11 @@ export function evidensposter(arende: Arende): Evidenspost[] {
export function evidensNiva(arende: Arende): EvidensNiva { export function evidensNiva(arende: Arende): EvidensNiva {
const poster = evidensposter(arende); const poster = evidensposter(arende);
const har = (n: string) => poster.some((p) => p.niva === n); const har = (n: string) => poster.some((p) => p.niva === n);
const kallor = [har("E2"), har("E4"), har("E5")].filter(Boolean).length; const kallor = [har("E2"), har("E3"), har("E4"), har("E5")].filter(Boolean).length;
if (kallor >= 2) return "E6"; if (kallor >= 2) return "E6";
if (har("E5")) return "E5"; if (har("E5")) return "E5";
if (har("E4")) return "E4"; if (har("E4")) return "E4";
if (har("E3")) return "E3";
if (har("E2")) return "E2"; if (har("E2")) return "E2";
if (har("E1")) return "E1"; if (har("E1")) return "E1";
return "E0"; return "E0";
@@ -173,6 +175,8 @@ export function underlagFinns(arende: Arende, kalla: string): boolean {
switch (kalla) { switch (kalla) {
case "Foto": case "Foto":
return h.some((x) => x.typ === "foto"); return h.some((x) => x.typ === "foto");
case "Video":
return h.some((x) => x.typ === "video");
case "Mätresultat": case "Mätresultat":
return h.some((x) => x.typ === "matvarde"); return h.some((x) => x.typ === "matvarde");
case "Diagnosutläsning": case "Diagnosutläsning":
@@ -183,7 +187,7 @@ export function underlagFinns(arende: Arende, kalla: string): boolean {
case "Direkt observation": case "Direkt observation":
return h.some((x) => x.typ === "observation" || x.typ === "kontroll_utford"); return h.some((x) => x.typ === "observation" || x.typ === "kontroll_utford");
default: default:
// Video/teknisk dokumentation: kan ännu inte verifieras maskinellt. // Teknisk dokumentation: kan ännu inte verifieras maskinellt.
return true; return true;
} }
} }
@@ -585,6 +589,20 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
}); });
} }
const avslutEvent = handelser.find((h) => h.typ === "arende_avslutat");
rader.push({
id: "signering",
rubrik: "Teknikerns slutsats signerad",
ok: !avslutat || (avslutEvent?.typ === "arende_avslutat" && !!avslutEvent.signatur),
kravs: false,
detalj:
avslutat && avslutEvent?.typ === "arende_avslutat" && avslutEvent.signatur
? `Signerad av ${avslutEvent.signatur}.`
: avslutat
? "Avslutet saknar signatur (äldre ärende)."
: "Signeras automatiskt när ärendet avslutas.",
});
const hypoteser = handelser.filter((h) => h.typ === "hypotes").length; const hypoteser = handelser.filter((h) => h.typ === "hypotes").length;
rader.push({ rader.push({
id: "hypoteser", id: "hypoteser",
+17
View File
@@ -29,3 +29,20 @@ export function skalaNerFoto(fil: File, maxSida = 1024): Promise<string> {
lasare.readAsDataURL(fil); lasare.readAsDataURL(fil);
}); });
} }
// Video läses rå (ingen transkodning i webbläsaren) — därför hård
// storleksgräns: kort och komprimerad klipp, ca 1015 sekunder.
export const MAX_VIDEO_BYTE = 2_500_000;
export function lasVideo(fil: File): Promise<string> {
return new Promise((los, avvisa) => {
if (fil.size > MAX_VIDEO_BYTE) {
avvisa(new Error("Videon är för stor — spela in ett kort klipp (ca 1015 sekunder)."));
return;
}
const lasare = new FileReader();
lasare.onload = () => los(lasare.result as string);
lasare.onerror = () => avvisa(new Error("Videon kunde inte läsas."));
lasare.readAsDataURL(fil);
});
}
+10
View File
@@ -182,6 +182,16 @@ export function foton(arende: Arende): { beskrivning: string; dataUrl: string; t
return resultat; return resultat;
} }
export function videor(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
for (const post of arende.handelser) {
if (post.handelse.typ === "video") {
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
}
}
return resultat;
}
// Tidsredovisning: varje intervall mellan två händelser tillhör den kategori // Tidsredovisning: varje intervall mellan två händelser tillhör den kategori
// som var aktiv när intervallet började. Kategori styrs av kategori_byte- // som var aktiv när intervallet började. Kategori styrs av kategori_byte-
// händelser; standard är aktiv felsökning. Paus räknas inte in i total tid. // händelser; standard är aktiv felsökning. Paus räknas inte in i total tid.
+74 -6
View File
@@ -11,6 +11,7 @@ import {
felbeskrivning, felbeskrivning,
formateraTid, formateraTid,
foton, foton,
videor,
lokalFordonshistorik, lokalFordonshistorik,
objekt, objekt,
overlamningstext, overlamningstext,
@@ -58,7 +59,7 @@ import {
underlagFinns, underlagFinns,
} from "@/felsokning/ecm"; } from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format"; import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format";
import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner"; import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner";
const FLIKAR = [ const FLIKAR = [
@@ -872,6 +873,7 @@ function GuideFlik({
nu: string; nu: string;
}) { }) {
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]); const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
const anvandare = useFelsokning((s) => s.anvandare);
const [visaOverlamning, setVisaOverlamning] = useState(false); const [visaOverlamning, setVisaOverlamning] = useState(false);
const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar"); const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar");
@@ -929,7 +931,7 @@ function GuideFlik({
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
med rekommenderade nästa steg. med rekommenderade nästa steg.
</p> </p>
<StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat" })}> <StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
Avsluta felsökning Avsluta felsökning
</StorKnapp> </StorKnapp>
{!kanAvslutas && ( {!kanAvslutas && (
@@ -1001,7 +1003,7 @@ function GuideFlik({
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}> <StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
Lämna över arbete Lämna över arbete
</StorKnapp> </StorKnapp>
<StorKnapp variant="sekundar" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat" })}> <StorKnapp variant="sekundar" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
Avsluta felsökning Avsluta felsökning
</StorKnapp> </StorKnapp>
</div> </div>
@@ -1211,6 +1213,7 @@ const DOKTYPER = [
{ id: "observation", label: "Observation" }, { id: "observation", label: "Observation" },
{ id: "matvarde", label: "Mätvärde" }, { id: "matvarde", label: "Mätvärde" },
{ id: "foto", label: "Foto" }, { id: "foto", label: "Foto" },
{ id: "video", label: "Video" },
{ id: "instrument", label: "Instrument" }, { id: "instrument", label: "Instrument" },
{ id: "hypotes", label: "Hypotes" }, { id: "hypotes", label: "Hypotes" },
{ id: "kommentar", label: "Kommentar" }, { id: "kommentar", label: "Kommentar" },
@@ -1232,6 +1235,9 @@ function SnabbDokumentation({
const [varde, setVarde] = useState(""); const [varde, setVarde] = useState("");
const filRef = useRef<HTMLInputElement>(null); const filRef = useRef<HTMLInputElement>(null);
const instRef = useRef<HTMLInputElement>(null); const instRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLInputElement>(null);
const [video, setVideo] = useState<{ dataUrl: string; beskrivning: string } | null>(null);
const [videoFel, setVideoFel] = useState("");
const [avlasning, setAvlasning] = useState<{ foto: string; tolkning: InstrumentTolkning; demo?: boolean } | null>(null); const [avlasning, setAvlasning] = useState<{ foto: string; tolkning: InstrumentTolkning; demo?: boolean } | null>(null);
const [laserAv, setLaserAv] = useState(false); const [laserAv, setLaserAv] = useState(false);
@@ -1309,9 +1315,11 @@ function SnabbDokumentation({
onClick={() => onClick={() =>
d.id === "foto" d.id === "foto"
? filRef.current?.click() ? filRef.current?.click()
: d.id === "instrument" : d.id === "video"
? instRef.current?.click() ? videoRef.current?.click()
: setTyp(typ === d.id ? null : d.id) : d.id === "instrument"
? instRef.current?.click()
: setTyp(typ === d.id ? null : d.id)
} }
className={`min-h-9 rounded border text-[12px] font-semibold transition-colors ${ className={`min-h-9 rounded border text-[12px] font-semibold transition-colors ${
typ === d.id typ === d.id
@@ -1349,6 +1357,52 @@ function SnabbDokumentation({
e.target.value = ""; e.target.value = "";
}} }}
/> />
<input
ref={videoRef}
type="file"
accept="video/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const fil = e.target.files?.[0];
e.target.value = "";
if (!fil) return;
setVideoFel("");
try {
setVideo({ dataUrl: await lasVideo(fil), beskrivning: "" });
} catch (misslyckande) {
setVideoFel(misslyckande instanceof Error ? misslyckande.message : "Videon kunde inte läsas.");
}
}}
/>
{videoFel && <p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">{videoFel}</p>}
{video && (
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-2">
<video src={video.dataUrl} controls className="mb-2 max-h-48 w-full rounded border border-[#C6C6C6]" />
<TextFalt
label="Vad visar videon? (obligatoriskt — det som låter eller rör sig)"
varde={video.beskrivning}
satt={(beskrivning) => setVideo((v) => (v ? { ...v, beskrivning } : v))}
platshallare="T.ex. Motorljud vid tomgång — tickande från topplocket"
rost
/>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVideo(null)}>
Förkasta
</StorKnapp>
<StorKnapp
disabled={!video.beskrivning.trim()}
onClick={() => {
skicka({ typ: "video", beskrivning: video.beskrivning.trim(), dataUrl: video.dataUrl });
paSparad?.(`Video dokumenterad: ${video.beskrivning.trim()}`);
setVideo(null);
}}
>
Spara video
</StorKnapp>
</div>
</div>
)}
{laserAv && <p className="mt-2 animate-pulse text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av instrumentet </p>} {laserAv && <p className="mt-2 animate-pulse text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av instrumentet </p>}
{avlasning && ( {avlasning && (
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-2"> <div className="mt-3 rounded border border-[#C6C6C6] bg-white p-2">
@@ -1594,6 +1648,9 @@ function LoggFlik({ arende }: { arende: Arende }) {
{post.handelse.typ === "foto" && ( {post.handelse.typ === "foto" && (
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" /> <img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
)} )}
{post.handelse.typ === "video" && (
<video src={post.handelse.dataUrl} controls className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
)}
</div> </div>
</li> </li>
))} ))}
@@ -1748,6 +1805,7 @@ function RapportFlik({
const anvandare = useFelsokning((s) => s.anvandare); const anvandare = useFelsokning((s) => s.anvandare);
const b = brief(arende, metodik, nu); const b = brief(arende, metodik, nu);
const bilder = foton(arende); const bilder = foton(arende);
const klipp = videor(arende);
const fordelning = tidsfordelningsRader(arende, nu); const fordelning = tidsfordelningsRader(arende, nu);
const idn = arendeidentitet(arende); const idn = arendeidentitet(arende);
const typ = arendetyp(arende); const typ = arendetyp(arende);
@@ -1927,6 +1985,16 @@ function RapportFlik({
</p> </p>
))} ))}
</Panel> </Panel>
{klipp.length > 0 && (
<Panel rubrik="Dokumenterad video">
{klipp.map((v, i) => (
<figure key={i} className="mb-2">
<video src={v.dataUrl} controls className="w-full rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
</figure>
))}
</Panel>
)}
{bilder.length > 0 && ( {bilder.length > 0 && (
<Panel rubrik="Dokumenterade bilder"> <Panel rubrik="Dokumenterade bilder">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">