Auftragsliste: lokale Scan-Tätigkeit nach oben + Geräte-Feld-Fundament

- Auftragsliste sortiert Aufträge mit lokalen Protokollen/Scans nach oben
  (lokale updatedAt), da Dolibarr-tms bei reiner App-Tätigkeit unverändert
  bleibt; Standard wieder "nur aktive Aufträge"
- Datumszeile zeigt "zuletzt bearb." aus lokalem Protokoll, sonst Server-tms
- Protokollzähler-Badge berücksichtigt auch lokale (noch nicht gesyncte) Protokolle
- types.ts: neue optionale Felder für kommende Geräte-Features
  (Device: isFavorite/customName/openPorts/netbiosName/mdnsName/mdnsServices;
  neu SavedScan, MonitorEvent, DeviceMonitorSession; Protocol.savedScans/monitorSessions)
- db.ts: normalizeProtocol ergänzt fehlende Arrays beim Laden alter Protokolle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 22:30:30 +02:00
parent 53d91d1526
commit 2a75ad96b2
3 changed files with 108 additions and 16 deletions

View file

@ -17,6 +17,20 @@ import type { Protocol } from './types';
const DB_NAME = 'netdiag';
const LS_PREFIX = 'netdiag.protocol.';
/**
* Fehlende optionale Felder eines geladenen Protokolls defensiv ergänzen.
* Ältere Protokolle (vor den Geräte-Features) haben weder `savedScans` noch
* `monitorSessions` ohne diese Normalisierung liefen Komponenten auf
* `undefined.map`. Mutiert das Objekt und gibt es zurück.
*/
function normalizeProtocol(p: Protocol): Protocol {
p.devices ??= [];
p.measurements ??= [];
p.savedScans ??= [];
p.monitorSessions ??= [];
return p;
}
let useSqlite = false;
let db: SQLiteDBConnection | null = null;
@ -66,10 +80,10 @@ export async function getProtocol(uuid: string): Promise<Protocol | null> {
if (useSqlite && db) {
const res = await db.query('SELECT json FROM protocols WHERE uuid = ?', [uuid]);
const row = res.values?.[0];
return row ? (JSON.parse(row.json) as Protocol) : null;
return row ? normalizeProtocol(JSON.parse(row.json) as Protocol) : null;
}
const raw = localStorage.getItem(LS_PREFIX + uuid);
return raw ? (JSON.parse(raw) as Protocol) : null;
return raw ? normalizeProtocol(JSON.parse(raw) as Protocol) : null;
}
/** Alle Protokolle laden (neueste zuerst) */
@ -77,12 +91,12 @@ export async function getAllProtocols(): Promise<Protocol[]> {
let list: Protocol[] = [];
if (useSqlite && db) {
const res = await db.query('SELECT json FROM protocols ORDER BY updated_at DESC');
list = (res.values ?? []).map((r) => JSON.parse(r.json) as Protocol);
list = (res.values ?? []).map((r) => normalizeProtocol(JSON.parse(r.json) as Protocol));
} else {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(LS_PREFIX)) {
list.push(JSON.parse(localStorage.getItem(key)!) as Protocol);
list.push(normalizeProtocol(JSON.parse(localStorage.getItem(key)!) as Protocol));
}
}
list.sort((a, b) => b.updatedAt - a.updatedAt);

View file

@ -54,6 +54,54 @@ export interface Device {
vendor?: string;
deviceType?: string;
note?: string;
/** vom Benutzer als Favorit markiert (bleibt auch ohne gespeicherten Scan erhalten) */
isFavorite?: boolean;
/** benutzervergebener Name, überschreibt hostname in der Anzeige */
customName?: string;
/** Unix-Zeit der letzten Sichtung im Netz */
lastSeen?: number;
/** zuletzt beim Port-Scan gefundene offene Ports */
openPorts?: number[];
/** NetBIOS-Name (UDP-137-Abfrage) */
netbiosName?: string;
/** mDNS/Bonjour-Name */
mdnsName?: string;
/** angebotene mDNS-Dienste, z.B. ['_printer._tcp', '_googlecast._tcp'] */
mdnsServices?: string[];
}
/** Eingefrorener Snapshot eines IP-Scans (wieder aufrufbar) */
export interface SavedScan {
id: string;
name: string;
/** Unix-Zeit der Speicherung */
createdAt: number;
subnet: string;
devices: Device[];
}
/** Ein Ereignis der Geräte-Überwachung */
export interface MonitorEvent {
/** Unix-Zeit des Ereignisses */
ts: number;
ip: string;
type: 'down' | 'up';
/** Dauer des vorangegangenen Ausfalls in Sekunden (nur bei 'up') */
durationSec?: number;
}
/** Überwachungs-Sitzung des Geräte-Monitors (Kamera-Problem) */
export interface DeviceMonitorSession {
id: string;
name: string;
startedAt: number;
endedAt?: number;
/** Prüfintervall in Sekunden */
intervalSec: number;
/** überwachte Geräte */
targets: { ip: string; label: string }[];
events: MonitorEvent[];
status: 'running' | 'stopped';
}
/** Ampel-Bewertung einer Messung */
@ -92,6 +140,10 @@ export interface Protocol {
note: string;
devices: Device[];
measurements: Measurement[];
/** gespeicherte IP-Scan-Snapshots (nur lokal, wird nicht synchronisiert) */
savedScans?: SavedScan[];
/** Geräte-Überwachungs-Sitzungen (nur lokal, wird nicht synchronisiert) */
monitorSessions?: DeviceMonitorSession[];
/** true solange noch nicht zum Server synchronisiert */
dirty: boolean;
updatedAt: number;

View file

@ -12,12 +12,15 @@
let orders = $state<Order[]>([]);
let search = $state('');
// Standard: alle Aufträge, zuletzt bearbeitete zuerst (Server sortiert nach tms).
// Haken setzen blendet abgeschlossene aus und zeigt nur aktive Aufträge.
let onlyActive = $state(false);
// Standard: nur aktive Aufträge. Haken entfernen zeigt auch abgeschlossene.
// Sortierung: Aufträge mit lokaler Scan-Tätigkeit zuerst, dann Server-Reihenfolge (tms).
let onlyActive = $state(true);
let loading = $state(false);
let loadError = $state('');
/** orderId → letzte lokale Protokoll-Bearbeitung + Anzahl (für Sortierung/Anzeige) */
let localActivity = $state(new Map<number, { updatedAt: number; count: number }>());
let searchTimer: ReturnType<typeof setTimeout>;
/** Kunde + Ort als eine Zeile — das, was man im Kopf hat */
@ -43,7 +46,23 @@
loadError = '';
try {
const res = await listOrders({ open: onlyActive, q: search.trim() || undefined });
orders = res.orders;
// Lokale Protokoll-Tätigkeit erfassen — ein Scan auf dem Handy ändert die
// Dolibarr-tms NICHT, daher hier clientseitig nach oben sortieren.
const activity = new Map<number, { updatedAt: number; count: number }>();
for (const p of await getAllProtocols()) {
if (p.orderId == null) continue;
const cur = activity.get(p.orderId);
activity.set(p.orderId, {
updatedAt: Math.max(cur?.updatedAt ?? 0, p.updatedAt),
count: (cur?.count ?? 0) + 1,
});
}
localActivity = activity;
// Aufträge mit lokaler Tätigkeit zuerst (jüngste oben), Rest in
// Server-Reihenfolge (tms DESC) — Array.sort ist stabil.
orders = res.orders
.slice()
.sort((a, b) => (activity.get(b.id)?.updatedAt ?? 0) - (activity.get(a.id)?.updatedAt ?? 0));
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen';
} finally {
@ -51,6 +70,15 @@
}
}
/** Datums-Zusatz für die Auftragszeile: lokale Bearbeitung bevorzugt */
function editedInfo(order: Order): string {
const local = localActivity.get(order.id);
if (local) return ' · zuletzt bearb. ' + fmtDate(local.updatedAt);
if (order.tms) return ' · bearb. ' + fmtDate(order.tms);
if (order.date) return ' · ' + fmtDate(order.date);
return '';
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(load, 300);
@ -84,7 +112,8 @@
}
onMount(async () => {
onlyActive = (await Preferences.get({ key: 'nd_only_active' })).value === '1';
const pref = (await Preferences.get({ key: 'nd_only_active' })).value;
onlyActive = pref == null ? true : pref === '1';
load();
});
</script>
@ -121,6 +150,7 @@
<p class="p-6 text-center text-sm text-zinc-500">Keine Aufträge gefunden.</p>
{:else}
{#each orders as order (order.id)}
{@const pc = Math.max(order.protocolCount ?? 0, localActivity.get(order.id)?.count ?? 0)}
<button
class="flex w-full items-start gap-3 border-b border-zinc-800/60 px-3 py-3 text-left active:bg-zinc-800"
onclick={() => openOrder(order)}
@ -145,16 +175,12 @@
{/if}
<!-- Auftragsnummer + Bearbeitungsdatum: nur Kleingedrucktes -->
<div class="truncate text-[11px] text-zinc-500">
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{order.tms
? ' · bearb. ' + fmtDate(order.tms)
: order.date
? ' · ' + fmtDate(order.date)
: ''}
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{editedInfo(order)}
</div>
</div>
{#if order.protocolCount && order.protocolCount > 0}
{#if pc > 0}
<span class="flex shrink-0 items-center gap-1 text-xs text-sky-400">
<FileStack size={14} />{order.protocolCount}
<FileStack size={14} />{pc}
</span>
{/if}
</button>