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:
+1
-1
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Ö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. |
|
||||
|
||||
@@ -23,7 +23,7 @@ omöjligt.
|
||||
| E0 | Inget underlag | 0 % |
|
||||
| E1 | Teknikerns observation | Lågt |
|
||||
| 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 |
|
||||
| E5 | Diagnosdata/dokument | Mycket högt |
|
||||
| E6 | Flera oberoende källor | Högsta |
|
||||
@@ -80,6 +80,7 @@ alla obligatoriska rader är gröna:
|
||||
| Metodikens kontroller: evidens eller dokumenterat undantag | Obligatorisk |
|
||||
| Foton för fotokrävande kontroller | Obligatorisk |
|
||||
| Ärendetypens compliance-krav | Obligatoriska |
|
||||
| Teknikerns slutsats signerad | Automatisk vid avslut |
|
||||
| Evidensnivå över E0 | Obligatorisk |
|
||||
|
||||
### 6. Traceability Engine
|
||||
|
||||
@@ -653,6 +653,7 @@ components:
|
||||
- arendetyp_satt
|
||||
- historik_kontrollerad
|
||||
- matarstallning
|
||||
- video
|
||||
- reproducering
|
||||
- felorsak
|
||||
- export_skapad
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { Arende } 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 { tidDatum, tidKlockslag } from "./format";
|
||||
import { FelsokningSkal, Panel } from "./ui";
|
||||
@@ -58,6 +58,7 @@ export function DelatArendeVy({
|
||||
const b = brief(arende, metodik, nu);
|
||||
const avslutat = arAvslutat(arende);
|
||||
const bilder = foton(arende);
|
||||
const klipp = videor(arende);
|
||||
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
||||
const kundposter = redanFiltrerad
|
||||
? arende.handelser
|
||||
@@ -102,6 +103,16 @@ export function DelatArendeVy({
|
||||
))}
|
||||
</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 && (
|
||||
<Panel rubrik="Bilder">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -335,3 +335,31 @@ describe("Fordonshistorik (lokal projektion)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,10 @@ export type Handelse =
|
||||
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
|
||||
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
|
||||
| { 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: "kategori_byte"; kategori: TidKategori }
|
||||
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
||||
@@ -104,7 +108,8 @@ export type Handelse =
|
||||
nastaSteg: 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 {
|
||||
id: string;
|
||||
@@ -160,6 +165,8 @@ export function handelseRubrik(post: LoggPost): string {
|
||||
return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`;
|
||||
case "foto":
|
||||
return `Foto: ${h.beskrivning}`;
|
||||
case "video":
|
||||
return `Video: ${h.beskrivning}`;
|
||||
case "kommentar":
|
||||
return h.text;
|
||||
case "kategori_byte":
|
||||
@@ -194,6 +201,6 @@ export function handelseRubrik(post: LoggPost): string {
|
||||
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
|
||||
}
|
||||
case "arende_avslutat":
|
||||
return "Felsökning avslutad";
|
||||
return h.signatur ? `Felsökning avslutad — signerad av ${h.signatur}` : "Felsökning avslutad";
|
||||
}
|
||||
}
|
||||
|
||||
+20
-2
@@ -77,6 +77,7 @@ export function evidensposter(arende: Arende): Evidenspost[] {
|
||||
for (const post of arende.handelser) {
|
||||
const h = post.handelse;
|
||||
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 === "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`);
|
||||
@@ -90,10 +91,11 @@ export function evidensposter(arende: Arende): Evidenspost[] {
|
||||
export function evidensNiva(arende: Arende): EvidensNiva {
|
||||
const poster = evidensposter(arende);
|
||||
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 (har("E5")) return "E5";
|
||||
if (har("E4")) return "E4";
|
||||
if (har("E3")) return "E3";
|
||||
if (har("E2")) return "E2";
|
||||
if (har("E1")) return "E1";
|
||||
return "E0";
|
||||
@@ -173,6 +175,8 @@ export function underlagFinns(arende: Arende, kalla: string): boolean {
|
||||
switch (kalla) {
|
||||
case "Foto":
|
||||
return h.some((x) => x.typ === "foto");
|
||||
case "Video":
|
||||
return h.some((x) => x.typ === "video");
|
||||
case "Mätresultat":
|
||||
return h.some((x) => x.typ === "matvarde");
|
||||
case "Diagnosutläsning":
|
||||
@@ -183,7 +187,7 @@ export function underlagFinns(arende: Arende, kalla: string): boolean {
|
||||
case "Direkt observation":
|
||||
return h.some((x) => x.typ === "observation" || x.typ === "kontroll_utford");
|
||||
default:
|
||||
// Video/teknisk dokumentation: kan ännu inte verifieras maskinellt.
|
||||
// Teknisk dokumentation: kan ännu inte verifieras maskinellt.
|
||||
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;
|
||||
rader.push({
|
||||
id: "hypoteser",
|
||||
|
||||
@@ -29,3 +29,20 @@ export function skalaNerFoto(fil: File, maxSida = 1024): Promise<string> {
|
||||
lasare.readAsDataURL(fil);
|
||||
});
|
||||
}
|
||||
|
||||
// Video läses rå (ingen transkodning i webbläsaren) — därför hård
|
||||
// storleksgräns: kort och komprimerad klipp, ca 10–15 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 10–15 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +182,16 @@ export function foton(arende: Arende): { beskrivning: string; dataUrl: string; t
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
felbeskrivning,
|
||||
formateraTid,
|
||||
foton,
|
||||
videor,
|
||||
lokalFordonshistorik,
|
||||
objekt,
|
||||
overlamningstext,
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
underlagFinns,
|
||||
} from "@/felsokning/ecm";
|
||||
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";
|
||||
|
||||
const FLIKAR = [
|
||||
@@ -872,6 +873,7 @@ function GuideFlik({
|
||||
nu: string;
|
||||
}) {
|
||||
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
|
||||
const anvandare = useFelsokning((s) => s.anvandare);
|
||||
const [visaOverlamning, setVisaOverlamning] = useState(false);
|
||||
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
|
||||
med rekommenderade nästa steg.
|
||||
</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
|
||||
</StorKnapp>
|
||||
{!kanAvslutas && (
|
||||
@@ -1001,7 +1003,7 @@ function GuideFlik({
|
||||
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
||||
Lämna över arbete
|
||||
</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
|
||||
</StorKnapp>
|
||||
</div>
|
||||
@@ -1211,6 +1213,7 @@ const DOKTYPER = [
|
||||
{ id: "observation", label: "Observation" },
|
||||
{ id: "matvarde", label: "Mätvärde" },
|
||||
{ id: "foto", label: "Foto" },
|
||||
{ id: "video", label: "Video" },
|
||||
{ id: "instrument", label: "Instrument" },
|
||||
{ id: "hypotes", label: "Hypotes" },
|
||||
{ id: "kommentar", label: "Kommentar" },
|
||||
@@ -1232,6 +1235,9 @@ function SnabbDokumentation({
|
||||
const [varde, setVarde] = useState("");
|
||||
const filRef = 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 [laserAv, setLaserAv] = useState(false);
|
||||
|
||||
@@ -1309,6 +1315,8 @@ function SnabbDokumentation({
|
||||
onClick={() =>
|
||||
d.id === "foto"
|
||||
? filRef.current?.click()
|
||||
: d.id === "video"
|
||||
? videoRef.current?.click()
|
||||
: d.id === "instrument"
|
||||
? instRef.current?.click()
|
||||
: setTyp(typ === d.id ? null : d.id)
|
||||
@@ -1349,6 +1357,52 @@ function SnabbDokumentation({
|
||||
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>}
|
||||
{avlasning && (
|
||||
<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" && (
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -1748,6 +1805,7 @@ function RapportFlik({
|
||||
const anvandare = useFelsokning((s) => s.anvandare);
|
||||
const b = brief(arende, metodik, nu);
|
||||
const bilder = foton(arende);
|
||||
const klipp = videor(arende);
|
||||
const fordelning = tidsfordelningsRader(arende, nu);
|
||||
const idn = arendeidentitet(arende);
|
||||
const typ = arendetyp(arende);
|
||||
@@ -1927,6 +1985,16 @@ function RapportFlik({
|
||||
</p>
|
||||
))}
|
||||
</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 && (
|
||||
<Panel rubrik="Dokumenterade bilder">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
Reference in New Issue
Block a user