Ein Befund davon ist ein Fehler aus Phase 4/5, den erst die Bestandsprüfung zutage gebracht hat: - MeasurementResult konnte nur Skalare und Textlisten. Die WLAN-Kanalmessung besteht aber aus verschachtelten Objekten (eigenesNetz, netze, empfehlung24GHz) — auf dem Handy stand dort "[object Object]", während dieselbe Messung in Dolibarr als saubere Tabelle aussah. Jetzt: "Eigenes Netz: FRITZ!Box 7590 SM · Kanal 6 · 2.4 GHz · -50 dBm · sehr gut". Weiter: - Ampel mit Form UND Zeichen statt nur Farbe (AmpelBadge.svelte): Kreis ✓, Dreieck !, Quadrat ✕, Raute ?. Rot-Grün-Sehschwäche betrifft rund 9 % der Männer, und im Sonnenlicht sind gesättigte Farben auf dem Handy ohnehin kaum zu unterscheiden. Ersetzt drei duplizierte Farbpunkt-Stellen. - Zeitstempel je Messung — bei mehreren Läufen desselben Werkzeugs war sonst nicht erkennbar, welche von wann ist. - messfelder.ts: deutsche Bezeichnungen mit Einheiten, Gegenstück zu netdiagKundenfelder() im Modul. Aus "verlustProzent: 0 · minMs: 4.4" wird "Paketverlust: 0 % · Kürzeste Antwortzeit: 4.4 ms". Bewusst KEINE Whitelist wie im Kundendokument — die App ist die Technikeransicht, unbekannte Schlüssel werden lesbar gemacht statt ausgeblendet. - Werkzeug-Klarnamen auch in der App: Messungen aus eigenen Routen (WLAN-Kanalanalyse, Monitor, IP-Test) haben keine Registry-ID, dort stand die rohe ID "wifikanal". - Fehler-Toasts bleiben 12 s statt 3 s stehen, erscheinen unten im Daumenbereich statt oben am Notch und lassen sich zum Schließen antippen. Sie weichen einer festen Aktionsleiste aus, statt den "Abschließen"-Knopf zu verdecken. - Kontrast: Messwerte von text-zinc-500 (3,67:1, unter WCAG-Minimum) auf text-zinc-400 (6,91:1), 11 -> 12 px, Zahlen mit tabular-nums. - setKeepAwake (nativ, FLAG_KEEP_SCREEN_ON): Bildschirm bleibt an, solange eine Messung läuft. Der WakeLock aus Phase 1 hält nur die CPU wach — das Display ging trotzdem aus, und man musste beim Zusehen ständig antippen. Im Emulator geprüft: WLAN-Kanalmessung wird vollständig lesbar dargestellt (Kennzahlen, Kanalempfehlung, Hinweise, Netzliste), Ampel als Kreis mit Haken, Zeitstempel, Klarname.
557 lines
20 KiB
Svelte
557 lines
20 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/stores';
|
|
import { App } from '@capacitor/app';
|
|
import type { PluginListenerHandle } from '@capacitor/core';
|
|
import AppHeader from '$lib/components/AppHeader.svelte';
|
|
import ToolDialog from '$lib/components/ToolDialog.svelte';
|
|
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
|
|
import AmpelBadge from '$lib/components/AmpelBadge.svelte';
|
|
import DeviceCard from '$lib/components/DeviceCard.svelte';
|
|
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
|
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
|
|
import {
|
|
addMeasurement,
|
|
upsertDevice,
|
|
toggleFavorite,
|
|
renameDevice,
|
|
saveScan,
|
|
deleteScan,
|
|
} from '$lib/protocols';
|
|
import { resumeFinishedStressSessions } from '$lib/stresstest';
|
|
import { sync } from '$lib/sync.svelte';
|
|
import { toast } from '$lib/toast.svelte';
|
|
import { pushOverlay } from '$lib/overlay.svelte';
|
|
import { TOOLS, getTool } from '$lib/tools';
|
|
import { werkzeugName } from '$lib/messfelder';
|
|
import type { Tool } from '$lib/tools/types';
|
|
import type { Device, Protocol } from '$lib/types';
|
|
import * as Icons from 'lucide-svelte';
|
|
|
|
let protocol = $state<Protocol | null>(null);
|
|
let activeTool = $state<Tool | null>(null);
|
|
let activeDevice = $state<Device | undefined>(undefined);
|
|
let saving = $state(false);
|
|
let renameTarget = $state<Device | null>(null);
|
|
let confirmDelete = $state(false);
|
|
let saveScanOpen = $state(false);
|
|
let expandedScan = $state<string | null>(null);
|
|
let deleteScanId = $state<string | null>(null);
|
|
|
|
let appStateListener: PluginListenerHandle | null = null;
|
|
|
|
// Stresstest ist seit Phase 2 KEIN Dialog-Werkzeug mehr — eigene Route mit
|
|
// Live-Anzeige (siehe stresstest.ts oben). Bleibt in TOOLS nur für
|
|
// getTool()-Namenslookups in der Messungen-Liste.
|
|
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest');
|
|
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
|
|
|
|
/** Uhrzeit einer Messung (Datum nur, wenn sie nicht von heute ist) */
|
|
function fmtUhrzeit(ts: number): string {
|
|
const d = new Date(ts);
|
|
const heute = new Date();
|
|
const gleicherTag =
|
|
d.getDate() === heute.getDate() &&
|
|
d.getMonth() === heute.getMonth() &&
|
|
d.getFullYear() === heute.getFullYear();
|
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +
|
|
(gleicherTag ? '' : ' · ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }));
|
|
}
|
|
|
|
/** IP-Adressen numerisch vergleichen (nicht als Text — "…9" vor "…10") */
|
|
function ipCompare(a: string, b: string): number {
|
|
const pa = a.split('.').map(Number);
|
|
const pb = b.split('.').map(Number);
|
|
for (let i = 0; i < 4; i++) {
|
|
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
if (diff !== 0) return diff;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** Geräte mit Favoriten zuerst, sonst numerisch nach IP */
|
|
const sortedDevices = $derived(
|
|
[...(protocol?.devices ?? [])].sort((a, b) => {
|
|
const fav = Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false);
|
|
return fav !== 0 ? fav : ipCompare(a.ip, b.ip);
|
|
}),
|
|
);
|
|
|
|
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
|
|
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
|
|
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
|
|
|
|
onMount(async () => {
|
|
const uuid = $page.params.id ?? '';
|
|
const p = await getProtocol(uuid);
|
|
if (!p) {
|
|
toast.show('Protokoll nicht gefunden', 'error');
|
|
goto('/auftraege/');
|
|
return;
|
|
}
|
|
protocol = p;
|
|
|
|
// Sicherheitsnetz: ein Dauertest kann natürlich auslaufen, während niemand
|
|
// auf der Stresstest-Seite ist (App im Hintergrund, anderes Werkzeug in
|
|
// Arbeit) — dort verpasst dann niemand das stressFinished-Event. Diese
|
|
// Seite wird vor dem Abschließen praktisch immer noch einmal geöffnet,
|
|
// deshalb hier zusätzlich nachsehen und das Ergebnis nachtragen.
|
|
if (await resumeFinishedStressSessions(protocol)) {
|
|
await persist();
|
|
}
|
|
|
|
// App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) →
|
|
// sofort sichern, bevor Android den Prozess evtl. beendet.
|
|
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
|
|
if (!isActive) void persist();
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
// Beim Verlassen der Seite (Back-Tap, Navigation) final sichern — fängt
|
|
// auch Eingaben ab, die noch nicht per onblur gespeichert wurden.
|
|
void persist();
|
|
appStateListener?.remove();
|
|
});
|
|
|
|
/** Protokoll als geändert markieren und lokal speichern */
|
|
async function persist() {
|
|
if (!protocol) return;
|
|
protocol.dirty = true;
|
|
await saveProtocol($state.snapshot(protocol) as Protocol);
|
|
await sync.refreshPending();
|
|
}
|
|
|
|
// Offenen Werkzeug-Dialog beim Hardware-Backbutton schließen, statt
|
|
// gleich die Seite zu verlassen.
|
|
$effect(() => {
|
|
if (!activeTool) return;
|
|
return pushOverlay(() => (activeTool = null));
|
|
});
|
|
|
|
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
|
|
function icon(name: string) {
|
|
const pascal = name
|
|
.split('-')
|
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
.join('');
|
|
const map = Icons as unknown as Record<string, unknown>;
|
|
return (map[pascal] ?? Icons.Wrench) as typeof Icons.Wrench;
|
|
}
|
|
|
|
function openTool(tool: Tool, device?: Device) {
|
|
activeTool = tool;
|
|
activeDevice = device;
|
|
}
|
|
|
|
/**
|
|
* Tool ausführen, Ergebnis ins Protokoll übernehmen.
|
|
* `live` ist nur bei `tool.supportsProgress` gesetzt (z.B. IP-Scanner) —
|
|
* ToolDialog reicht darüber Fortschritt/Abbruch bis ins Tool durch.
|
|
*/
|
|
async function runTool(
|
|
params: Record<string, string | number>,
|
|
live?: {
|
|
onProgress?: (p: { done: number; total: number; found: number; foundIps?: string[] }) => void;
|
|
isCancelled?: () => boolean;
|
|
},
|
|
) {
|
|
if (!protocol || !activeTool) return;
|
|
const tool = activeTool;
|
|
const result = await tool.run({
|
|
params,
|
|
protocol,
|
|
device: activeDevice,
|
|
onProgress: live?.onProgress,
|
|
isCancelled: live?.isCancelled,
|
|
});
|
|
|
|
// Änderungen am Protokoll selbst (z.B. der beim IP-Scan erkannte
|
|
// Netzbereich) gezielt hier anwenden — Tools dürfen ctx.protocol nicht
|
|
// direkt beschreiben (siehe ToolRunResult.protocolPatch).
|
|
if (result.protocolPatch) {
|
|
Object.assign(protocol, result.protocolPatch);
|
|
}
|
|
|
|
// Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten
|
|
// Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …)
|
|
if (result.devices) {
|
|
for (const d of result.devices) {
|
|
upsertDevice(protocol, { ...d, lastSeen: Date.now() });
|
|
}
|
|
}
|
|
|
|
addMeasurement(protocol, {
|
|
deviceClientId: activeDevice?.clientId ?? null,
|
|
tool: tool.id,
|
|
category: tool.category,
|
|
label: result.label,
|
|
params,
|
|
result: result.result,
|
|
measureStatus: result.measureStatus,
|
|
dateMeasure: Date.now(),
|
|
});
|
|
await persist();
|
|
toast.show(`${tool.name}: ${result.label}`, result.measureStatus === 2 ? 'error' : 'success');
|
|
}
|
|
|
|
/** Protokoll abschließen und synchronisieren */
|
|
async function finish() {
|
|
if (!protocol) return;
|
|
saving = true;
|
|
protocol.status = 1;
|
|
await persist();
|
|
await sync.syncNow();
|
|
saving = false;
|
|
toast.show(
|
|
sync.status === 'error' ? 'Gespeichert — Sync folgt bei Verbindung' : 'Abgeschlossen & synchronisiert',
|
|
sync.status === 'error' ? 'info' : 'success',
|
|
);
|
|
}
|
|
|
|
/** Favoriten-Stern eines Geräts umschalten */
|
|
function doToggleFav(device: Device) {
|
|
if (!protocol) return;
|
|
toggleFavorite(protocol, device.clientId);
|
|
void persist();
|
|
}
|
|
|
|
/** Gerät umbenennen (aus dem Namens-Dialog) */
|
|
function doRename(name: string) {
|
|
if (protocol && renameTarget) {
|
|
renameDevice(protocol, renameTarget.clientId, name);
|
|
void persist();
|
|
}
|
|
renameTarget = null;
|
|
}
|
|
|
|
async function doDelete() {
|
|
confirmDelete = false;
|
|
if (!protocol) return;
|
|
await deleteProtocol(protocol.clientUuid);
|
|
await sync.refreshPending();
|
|
goto('/auftraege/');
|
|
}
|
|
|
|
/** Datum + Uhrzeit kurz */
|
|
function fmtDateTime(ts: number): string {
|
|
return new Date(ts).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
/** Vorschlag für den Scan-Namen */
|
|
function defaultScanName(): string {
|
|
return 'Scan ' + fmtDateTime(Date.now());
|
|
}
|
|
|
|
/** Aktuellen Geräte-Stand als Snapshot speichern */
|
|
function doSaveScan(name: string) {
|
|
if (protocol) {
|
|
saveScan(protocol, name);
|
|
void persist();
|
|
toast.show('Scan gespeichert', 'success');
|
|
}
|
|
saveScanOpen = false;
|
|
}
|
|
|
|
function doDeleteScan() {
|
|
if (protocol && deleteScanId) {
|
|
deleteScan(protocol, deleteScanId);
|
|
void persist();
|
|
}
|
|
deleteScanId = null;
|
|
}
|
|
|
|
function measurementsFor(deviceClientId: string) {
|
|
return protocol?.measurements.filter((m) => m.deviceClientId === deviceClientId) ?? [];
|
|
}
|
|
function protocolMeasurements() {
|
|
return protocol?.measurements.filter((m) => !m.deviceClientId) ?? [];
|
|
}
|
|
</script>
|
|
|
|
{#if protocol}
|
|
<AppHeader
|
|
title={protocol.label}
|
|
subtitle={protocol.socName || protocol.orderRef || 'Diagnose'}
|
|
back
|
|
/>
|
|
|
|
<div class="flex-1 overflow-y-auto pb-24">
|
|
<!-- Stammdaten -->
|
|
<section class="flex flex-col gap-2 border-b border-zinc-800 p-3">
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
Standort
|
|
<input
|
|
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
|
|
bind:value={protocol.location}
|
|
onblur={persist}
|
|
placeholder="Gebäude / Raum"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
Netzbereich
|
|
<input
|
|
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
|
|
bind:value={protocol.subnet}
|
|
onblur={persist}
|
|
placeholder="192.168.1.0/24"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
Notiz
|
|
<textarea
|
|
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
|
|
rows="2"
|
|
bind:value={protocol.note}
|
|
onblur={persist}
|
|
></textarea>
|
|
</label>
|
|
</section>
|
|
|
|
<!-- Werkzeuge -->
|
|
<section class="p-3">
|
|
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Werkzeuge</h2>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
{#each protocolTools as tool (tool.id)}
|
|
{@const IconC = icon(tool.icon)}
|
|
<button
|
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 text-left active:bg-zinc-700"
|
|
onclick={() => openTool(tool)}
|
|
>
|
|
<IconC size={20} class="text-sky-400" />
|
|
<span class="text-sm font-medium">{tool.name}</span>
|
|
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
|
|
</button>
|
|
{/each}
|
|
<!-- Dauer-/Stresstest: eigene Seite (Live-Diagramm + Foreground-Service) -->
|
|
<a
|
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
|
href="/protokoll/{protocol.clientUuid}/stresstest/"
|
|
>
|
|
<Icons.Gauge size={20} class="text-sky-400" />
|
|
<span class="text-sm font-medium">Dauer-/Stresstest</span>
|
|
<span class="text-[11px] leading-tight text-zinc-500">
|
|
Langzeitmessung: Paketverlust und Latenz über einen Zeitraum, live sichtbar.
|
|
</span>
|
|
</a>
|
|
<!-- Geräte-Monitor: eigene Seite (Mehrfachauswahl + Dauerlauf) -->
|
|
<a
|
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
|
href="/protokoll/{protocol.clientUuid}/monitor/"
|
|
>
|
|
<Icons.Activity size={20} class="text-sky-400" />
|
|
<span class="text-sm font-medium">Geräte-Monitor</span>
|
|
<span class="text-[11px] leading-tight text-zinc-500">
|
|
Erreichbarkeit mehrerer Geräte dauerhaft überwachen.
|
|
</span>
|
|
</a>
|
|
<!-- IP-Test: USB-RJ45 in Dose stecken, IP + Link-Speed live anzeigen -->
|
|
<a
|
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
|
href="/protokoll/{protocol.clientUuid}/iptest/"
|
|
>
|
|
<Icons.Cable size={20} class="text-sky-400" />
|
|
<span class="text-sm font-medium">IP-Test</span>
|
|
<span class="text-[11px] leading-tight text-zinc-500">
|
|
Dose prüfen: IP, DHCP und 10/100/1000 Mbit ablesen.
|
|
</span>
|
|
</a>
|
|
<!-- WLAN-Empfangstracker: Netz anklicken, durchs Gebäude laufen -->
|
|
<a
|
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
|
href="/protokoll/{protocol.clientUuid}/wifi/"
|
|
>
|
|
<Icons.Wifi size={20} class="text-sky-400" />
|
|
<span class="text-sm font-medium">WLAN-Empfang</span>
|
|
<span class="text-[11px] leading-tight text-zinc-500">
|
|
Empfangsstärke beim Durchgehen aufzeichnen.
|
|
</span>
|
|
</a>
|
|
<!-- WLAN-Kanalanalyse: alle Netze auf einen Blick, Kanalgraph -->
|
|
<a
|
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
|
href="/protokoll/{protocol.clientUuid}/wifikanal/"
|
|
>
|
|
<Icons.Radio size={20} class="text-sky-400" />
|
|
<span class="text-sm font-medium">WLAN-Kanäle</span>
|
|
<span class="text-[11px] leading-tight text-zinc-500">
|
|
Kanalbelegung, Überlappung und Kanalempfehlung — alle Netze auf einen Blick.
|
|
</span>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Protokoll-Messungen -->
|
|
{#if protocolMeasurements().length > 0}
|
|
<section class="px-3 pb-3">
|
|
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Messungen</h2>
|
|
{#each protocolMeasurements() as m (m.clientId)}
|
|
<div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5">
|
|
<div class="flex items-center gap-2">
|
|
<AmpelBadge status={m.measureStatus} />
|
|
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? werkzeugName(m.tool)}</span>
|
|
<!-- Zeitstempel: bei mehreren Läufen desselben Werkzeugs sonst
|
|
nicht erkennbar, welche Messung von wann ist -->
|
|
<span class="ml-auto shrink-0 text-[11px] text-zinc-500">{fmtUhrzeit(m.dateMeasure)}</span>
|
|
</div>
|
|
<p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
|
<div class="mt-0.5"><MeasurementResult result={m.result} /></div>
|
|
</div>
|
|
{/each}
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- Geräte -->
|
|
<section class="px-3 pb-3">
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-zinc-300">
|
|
Geräte ({protocol.devices.length})
|
|
</h2>
|
|
{#if protocol.devices.length > 0}
|
|
<button class="text-xs text-sky-300 active:text-sky-200" onclick={() => (saveScanOpen = true)}>
|
|
Scan speichern
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{#if protocol.devices.length === 0}
|
|
<p class="text-xs text-zinc-500">
|
|
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
|
|
</p>
|
|
{/if}
|
|
{#each sortedDevices as device (device.clientId)}
|
|
<DeviceCard
|
|
{device}
|
|
measurements={measurementsFor(device.clientId)}
|
|
tools={deviceTools}
|
|
onrun={(tool) => openTool(tool, device)}
|
|
onfavorite={() => doToggleFav(device)}
|
|
onrename={() => (renameTarget = device)}
|
|
/>
|
|
{/each}
|
|
</section>
|
|
|
|
<!-- Gespeicherte Scans -->
|
|
{#if protocol.savedScans && protocol.savedScans.length > 0}
|
|
<section class="px-3 pb-3">
|
|
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Gespeicherte Scans</h2>
|
|
{#each protocol.savedScans as scan (scan.id)}
|
|
<div class="mb-1.5 rounded-lg bg-zinc-900">
|
|
<button
|
|
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
|
|
onclick={() => (expandedScan = expandedScan === scan.id ? null : scan.id)}
|
|
>
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-medium">{scan.name}</div>
|
|
<div class="text-[11px] text-zinc-500">
|
|
{scan.devices.length} Geräte · {scan.subnet || '—'} · {fmtDateTime(scan.createdAt)}
|
|
</div>
|
|
</div>
|
|
<Icons.ChevronDown
|
|
size={16}
|
|
class="shrink-0 text-zinc-500 {expandedScan === scan.id ? 'rotate-180' : ''}"
|
|
/>
|
|
</button>
|
|
{#if expandedScan === scan.id}
|
|
<div class="border-t border-zinc-800 p-2.5">
|
|
{#each scan.devices as d (d.clientId)}
|
|
<DeviceCard device={d} />
|
|
{/each}
|
|
<button
|
|
class="mt-1 text-xs text-red-400 underline"
|
|
onclick={() => (deleteScanId = scan.id)}
|
|
>
|
|
Scan löschen
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</section>
|
|
{/if}
|
|
|
|
<div class="px-3">
|
|
<button class="text-xs text-red-400 underline" onclick={() => (confirmDelete = true)}>
|
|
Protokoll löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Abschluss-Leiste -->
|
|
<!-- Feste Aktionsleiste. Sie setzt --leiste-hoehe, damit die Kurzmeldungen
|
|
darüber erscheinen statt den Abschließen-Knopf zu verdecken. -->
|
|
<div
|
|
class="fixed inset-x-0 bottom-0 border-t border-zinc-800 bg-zinc-900 p-3 safe-bottom"
|
|
style="--leiste: 1"
|
|
>
|
|
<button
|
|
class="w-full rounded-lg bg-emerald-600 py-2.5 font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
|
|
onclick={finish}
|
|
disabled={saving}
|
|
>
|
|
{protocol.status === 1 ? 'Erneut synchronisieren' : 'Abschließen & synchronisieren'}
|
|
</button>
|
|
</div>
|
|
|
|
{#if activeTool}
|
|
<ToolDialog
|
|
tool={activeTool}
|
|
{protocol}
|
|
device={activeDevice}
|
|
onclose={() => (activeTool = null)}
|
|
onrun={runTool}
|
|
/>
|
|
{/if}
|
|
|
|
{#if renameTarget}
|
|
<TextPromptDialog
|
|
title="Gerät benennen"
|
|
label="Eigener Name"
|
|
value={renameTarget.customName ?? ''}
|
|
placeholder={renameTarget.hostname ?? renameTarget.ip}
|
|
onsubmit={doRename}
|
|
oncancel={() => (renameTarget = null)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if confirmDelete}
|
|
<ConfirmDialog
|
|
title="Protokoll löschen?"
|
|
message="Das Protokoll und alle Messungen werden lokal entfernt."
|
|
confirmLabel="Löschen"
|
|
danger
|
|
onconfirm={doDelete}
|
|
oncancel={() => (confirmDelete = false)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if saveScanOpen}
|
|
<TextPromptDialog
|
|
title="Scan speichern"
|
|
label="Name des Scans"
|
|
value={defaultScanName()}
|
|
placeholder="z.B. Erdgeschoss"
|
|
onsubmit={doSaveScan}
|
|
oncancel={() => (saveScanOpen = false)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if deleteScanId}
|
|
<ConfirmDialog
|
|
title="Scan löschen?"
|
|
message="Der gespeicherte Scan-Snapshot wird entfernt."
|
|
confirmLabel="Löschen"
|
|
danger
|
|
onconfirm={doDeleteScan}
|
|
oncancel={() => (deleteScanId = null)}
|
|
/>
|
|
{/if}
|
|
{:else}
|
|
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
|
|
{/if}
|