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. |
|
| 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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -653,6 +653,7 @@ components:
|
|||||||
- arendetyp_satt
|
- arendetyp_satt
|
||||||
- historik_kontrollerad
|
- historik_kontrollerad
|
||||||
- matarstallning
|
- matarstallning
|
||||||
|
- video
|
||||||
- reproducering
|
- reproducering
|
||||||
- felorsak
|
- felorsak
|
||||||
- export_skapad
|
- export_skapad
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 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;
|
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.
|
||||||
|
|||||||
@@ -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,6 +1315,8 @@ function SnabbDokumentation({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
d.id === "foto"
|
d.id === "foto"
|
||||||
? filRef.current?.click()
|
? filRef.current?.click()
|
||||||
|
: d.id === "video"
|
||||||
|
? videoRef.current?.click()
|
||||||
: d.id === "instrument"
|
: d.id === "instrument"
|
||||||
? instRef.current?.click()
|
? instRef.current?.click()
|
||||||
: setTyp(typ === d.id ? null : d.id)
|
: setTyp(typ === d.id ? null : 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user