From 1218c1366037329ab4bfda8a85889ff29de75e7e Mon Sep 17 00:00:00 2001 From: linus Date: Mon, 27 Jul 2026 21:18:28 +0200 Subject: [PATCH] Add SQL management UI, maintenance, and trip edit pages for motorcycles --- src/app/(app)/admin/database/page.tsx | 212 +++++++++ src/app/(app)/layout.tsx | 7 +- src/app/(app)/motorcycles/[id]/edit/page.tsx | 8 +- .../[id]/maintenance/[eventId]/edit/page.tsx | 113 +++++ .../[id]/maintenance/edit/page.tsx | 9 +- .../motorcycles/[id]/maintenance/page.tsx | 8 +- src/app/(app)/motorcycles/[id]/page.tsx | 139 ++++-- .../[id]/trips/[tripId]/edit/page.tsx | 98 +++++ src/app/(app)/page.tsx | 89 ++-- src/app/actions/database.ts | 114 +++++ src/app/actions/motorcycles.ts | 410 +++++++++++++++--- src/app/globals.css | 71 +++ src/app/kiosk/page.tsx | 8 +- src/components/database-console.tsx | 79 ++++ src/components/maintenance-form.tsx | 275 ++++++++++-- src/components/motorcycle-form.tsx | 5 - src/components/trip-form.tsx | 52 ++- src/lib/db.ts | 64 +++ src/lib/format.ts | 4 + src/lib/types.ts | 1 - src/lib/validation.ts | 8 +- 21 files changed, 1562 insertions(+), 212 deletions(-) create mode 100644 src/app/(app)/admin/database/page.tsx create mode 100644 src/app/(app)/motorcycles/[id]/maintenance/[eventId]/edit/page.tsx create mode 100644 src/app/(app)/motorcycles/[id]/trips/[tripId]/edit/page.tsx create mode 100644 src/app/actions/database.ts create mode 100644 src/components/database-console.tsx diff --git a/src/app/(app)/admin/database/page.tsx b/src/app/(app)/admin/database/page.tsx new file mode 100644 index 0000000..fce31c3 --- /dev/null +++ b/src/app/(app)/admin/database/page.tsx @@ -0,0 +1,212 @@ +import Link from "next/link"; +import { DatabaseConsole } from "@/components/database-console"; +import { requireAdminUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; + +export const runtime = "nodejs"; + +const PAGE_SIZE = 50; + +type DatabaseObject = { + name: string; + type: "table" | "view"; + sql: string | null; + rowCount: number; +}; + +type ColumnInfo = { + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +}; + +function quoteIdentifier(value: string) { + return `"${value.replaceAll('"', '""')}"`; +} + +function displayValue(value: unknown) { + if (value == null) return "NULL"; + if (value instanceof Uint8Array) return `[BLOB: ${value.byteLength} Bytes]`; + const text = typeof value === "bigint" ? value.toString() : String(value); + return text.length > 500 ? `${text.slice(0, 500)}…` : text; +} + +export default async function AdminDatabasePage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireAdminUser(); + initDatabase(); + const params = await searchParams; + const requestedTable = typeof params.table === "string" ? params.table : ""; + const requestedPage = typeof params.page === "string" ? Number(params.page) : 1; + const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1; + + const objects = (db + .prepare( + `SELECT name, type, sql + FROM sqlite_schema + WHERE type IN ('table', 'view') + ORDER BY type, name COLLATE NOCASE`, + ) + .all() as Omit[]).map((object) => { + const count = db + .prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(object.name)}`) + .get() as { count: number }; + return { ...object, rowCount: count.count }; + }); + const selected = objects.find((object) => object.name === requestedTable) ?? objects[0] ?? null; + + let columns: ColumnInfo[] = []; + let rows: Record[] = []; + let totalPages = 1; + let currentPage = 1; + if (selected) { + columns = db + .prepare(`PRAGMA table_info(${quoteIdentifier(selected.name)})`) + .all() as ColumnInfo[]; + totalPages = Math.max(1, Math.ceil(selected.rowCount / PAGE_SIZE)); + currentPage = Math.min(page, totalPages); + rows = db + .prepare(`SELECT * FROM ${quoteIdentifier(selected.name)} LIMIT ? OFFSET ?`) + .all(PAGE_SIZE, (currentPage - 1) * PAGE_SIZE) as Record[]; + } + + return ( + <> +
+ Übersicht + / + Datenbank +
+
+
+

Administration

+

Datenbankverwaltung

+

+ Vollzugriff auf Tabellen, Ansichten und SQL-Befehle. Änderungen wirken sofort. +

+
+
+ +
+ + +
+ {selected && ( + <> +
+

{selected.type === "table" ? "Tabelle" : "Ansicht"}

+

{selected.name}

+
{selected.sql ?? "Kein Schema verfügbar."}
+
+ +
+
+
+

Struktur

+

Spalten

+
+
+
+ + + + + + + + + + + + {columns.map((column) => ( + + + + + + + + ))} + +
NameTypPflichtStandardPrimärschlüssel
{column.name}{column.type || "—"}{column.notnull ? "Ja" : "Nein"}{column.dflt_value ?? "—"}{column.pk ? `Position ${column.pk}` : "—"}
+
+
+ +
+
+
+

Daten

+

{selected.rowCount} Datensätze

+
+ {totalPages > 1 && ( +
+ {currentPage > 1 && ( + + Zurück + + )} + Seite {currentPage} / {totalPages} + {currentPage < totalPages && ( + + Weiter + + )} +
+ )} +
+
+ {rows.length === 0 ? ( +

Keine Datensätze vorhanden.

+ ) : ( + + + + {Object.keys(rows[0]).map((column) => )} + + + + {rows.map((row, rowIndex) => ( + + {Object.entries(row).map(([column, value]) => ( + + ))} + + ))} + +
{column}
{displayValue(value)}
+ )} +
+
+ + )} +
+
+ + + + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index d3da779..bb8bed9 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -19,7 +19,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
diff --git a/src/app/(app)/motorcycles/[id]/edit/page.tsx b/src/app/(app)/motorcycles/[id]/edit/page.tsx index a771aa6..5e27a74 100644 --- a/src/app/(app)/motorcycles/[id]/edit/page.tsx +++ b/src/app/(app)/motorcycles/[id]/edit/page.tsx @@ -4,14 +4,13 @@ import { Flash } from "@/components/flash"; import { MotorcycleForm } from "@/components/motorcycle-form"; import { requireSessionUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; -import { queryMessage } from "@/lib/format"; +import { formatMotorcycleName, queryMessage } from "@/lib/format"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; export const runtime = "nodejs"; type MotorcycleValue = { id: number; - nickname: string; brand: string; model: string; year: number | null; @@ -52,7 +51,6 @@ export default async function EditMotorcyclePage({ if (!bike) notFound(); const motorcycleForForm: MotorcycleValue = { id: bike.id, - nickname: bike.nickname, brand: bike.brand, model: bike.model, year: bike.year, @@ -80,7 +78,9 @@ export default async function EditMotorcyclePage({
Übersicht / - {bike.nickname} + + {formatMotorcycleName(bike.brand, bike.model)} + / Bearbeiten
diff --git a/src/app/(app)/motorcycles/[id]/maintenance/[eventId]/edit/page.tsx b/src/app/(app)/motorcycles/[id]/maintenance/[eventId]/edit/page.tsx new file mode 100644 index 0000000..86cd3e0 --- /dev/null +++ b/src/app/(app)/motorcycles/[id]/maintenance/[eventId]/edit/page.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Flash } from "@/components/flash"; +import { + MaintenanceForm, + type MaintenanceItemValue, + type MaintenanceValue, +} from "@/components/maintenance-form"; +import { requireSessionUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { formatMotorcycleName, queryMessage } from "@/lib/format"; +import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; + +export const runtime = "nodejs"; + +type MaintenanceEditRow = Omit & { + motorcycle_id: number; + motorcycle_brand: string; + motorcycle_model: string; +}; + +export default async function EditMaintenancePage({ + params, + searchParams, +}: { + params: Promise<{ id: string; eventId: string }>; + searchParams: Promise>; +}) { + const user = await requireSessionUser(); + initDatabase(); + const { id, eventId } = await params; + const motorcycleId = Number(id); + const maintenanceEventId = Number(eventId); + if ( + !Number.isSafeInteger(motorcycleId) || + motorcycleId < 1 || + !Number.isSafeInteger(maintenanceEventId) || + maintenanceEventId < 1 + ) { + notFound(); + } + + const visible = motorcycleAccessFilter(user); + const maintenanceRow = db + .prepare( + `SELECT e.id, e.motorcycle_id, e.description, e.event_date, e.km, + m.brand AS motorcycle_brand, m.model AS motorcycle_model, + a.id AS attachment_id, a.original_name AS attachment_name + FROM maintenance_events e + JOIN motorcycles m ON m.id = e.motorcycle_id + LEFT JOIN attachments a ON a.maintenance_event_id = e.id + WHERE e.id = ? AND e.motorcycle_id = ? AND ${visible.clause}`, + ) + .get(maintenanceEventId, motorcycleId, ...visible.params) as MaintenanceEditRow | undefined; + if (!maintenanceRow) notFound(); + const itemRows = db + .prepare( + `SELECT id, type, custom_type, cost_cents, due_date, due_km + FROM maintenance_event_items + WHERE maintenance_event_id = ? + ORDER BY position`, + ) + .all(maintenanceEventId) as MaintenanceItemValue[]; + const maintenance: MaintenanceValue = { + id: maintenanceRow.id, + description: maintenanceRow.description, + event_date: maintenanceRow.event_date, + km: maintenanceRow.km, + attachment_id: maintenanceRow.attachment_id, + attachment_name: maintenanceRow.attachment_name, + items: itemRows.map((item) => ({ + id: item.id, + type: item.type, + custom_type: item.custom_type, + cost_cents: item.cost_cents, + due_date: item.due_date, + due_km: item.due_km, + })), + }; + + const params_ = await searchParams; + + return ( + <> +
+ Übersicht + / + + {formatMotorcycleName( + maintenanceRow.motorcycle_brand, + maintenanceRow.motorcycle_model, + )} + + / + Wartungseintrag bearbeiten +
+
+
+

Werkstattbuch

+

Wartungseintrag bearbeiten

+
+
+ + + + ); +} diff --git a/src/app/(app)/motorcycles/[id]/maintenance/edit/page.tsx b/src/app/(app)/motorcycles/[id]/maintenance/edit/page.tsx index 942d266..f1cd335 100644 --- a/src/app/(app)/motorcycles/[id]/maintenance/edit/page.tsx +++ b/src/app/(app)/motorcycles/[id]/maintenance/edit/page.tsx @@ -4,14 +4,13 @@ import { Flash } from "@/components/flash"; import { MaintenanceSpecsForm } from "@/components/maintenance-specs-form"; import { requireSessionUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; -import { queryMessage } from "@/lib/format"; +import { formatMotorcycleName, queryMessage } from "@/lib/format"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; export const runtime = "nodejs"; type MaintenanceEditRow = { id: number; - nickname: string; brand: string; model: string; year: number | null; @@ -44,7 +43,7 @@ export default async function MotorcycleMaintenanceEditPage({ const visible = motorcycleAccessFilter(user); const bike = db .prepare( - `SELECT id, nickname, brand, model, year, + `SELECT id, brand, model, year, tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm, oil_type, oil_capacity_liters, oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method @@ -75,7 +74,9 @@ export default async function MotorcycleMaintenanceEditPage({
Übersicht / - {bike.nickname} + + {formatMotorcycleName(bike.brand, bike.model)} + / Wartungsansicht / diff --git a/src/app/(app)/motorcycles/[id]/maintenance/page.tsx b/src/app/(app)/motorcycles/[id]/maintenance/page.tsx index 0e026be..ac1c719 100644 --- a/src/app/(app)/motorcycles/[id]/maintenance/page.tsx +++ b/src/app/(app)/motorcycles/[id]/maintenance/page.tsx @@ -2,13 +2,13 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { requireSessionUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; +import { formatMotorcycleName } from "@/lib/format"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; export const runtime = "nodejs"; type MotorcycleMaintenanceRow = { id: number; - nickname: string; brand: string; model: string; year: number | null; @@ -108,7 +108,7 @@ export default async function MotorcycleMaintenancePage({ const visible = motorcycleAccessFilter(user); const bike = db .prepare( - `SELECT id, nickname, brand, model, year, + `SELECT id, brand, model, year, tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm, oil_type, oil_capacity_liters, oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method @@ -126,7 +126,9 @@ export default async function MotorcycleMaintenancePage({
Übersicht / - {bike.nickname} + + {formatMotorcycleName(bike.brand, bike.model)} + / Wartungsansicht
diff --git a/src/app/(app)/motorcycles/[id]/page.tsx b/src/app/(app)/motorcycles/[id]/page.tsx index 7c81794..c5e0400 100644 --- a/src/app/(app)/motorcycles/[id]/page.tsx +++ b/src/app/(app)/motorcycles/[id]/page.tsx @@ -14,7 +14,13 @@ import { StatusPill } from "@/components/status-pill"; import { TripForm } from "@/components/trip-form"; import { requireSessionUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; -import { formatDate, formatKm, formatMoney, queryMessage } from "@/lib/format"; +import { + formatDate, + formatKm, + formatMoney, + formatMotorcycleName, + queryMessage, +} from "@/lib/format"; import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { getDueStatus } from "@/lib/status"; import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; @@ -23,7 +29,6 @@ export const runtime = "nodejs"; type MotorcycleRow = { id: number; - nickname: string; brand: string; model: string; year: number | null; @@ -48,17 +53,23 @@ type MotorcycleRow = { type EventRow = { id: number; - type: MaintenanceType; - title: string; description: string | null; event_date: string; km: number | null; - cost_cents: number | null; - due_date: string | null; - due_km: number | null; creator: string; attachment_id: number | null; attachment_name: string | null; + items: MaintenanceItemRow[]; +}; + +type MaintenanceItemRow = { + id: number; + maintenance_event_id: number; + type: MaintenanceType | null; + custom_type: string | null; + cost_cents: number | null; + due_date: string | null; + due_km: number | null; }; type TripRow = { @@ -76,11 +87,15 @@ type ShareRow = { name: string; }; -function latestDue(events: EventRow[], type: MaintenanceType) { - const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null)); +function latestDue(items: MaintenanceItemRow[], type: MaintenanceType) { + const match = items.find((item) => item.type === type); return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null }; } +function maintenanceItemLabel(item: MaintenanceItemRow) { + return item.type ? maintenanceLabels[item.type] : item.custom_type ?? "Eigene Art"; +} + export default async function MotorcycleDetailPage({ params, searchParams, @@ -108,10 +123,10 @@ export default async function MotorcycleDetailPage({ const params_ = await searchParams; - const events = db + const eventRows = db .prepare( - `SELECT e.id, e.type, e.title, e.description, e.event_date, e.km, e.cost_cents, - e.due_date, e.due_km, COALESCE(u.name, 'Gelöschter Benutzer') AS creator, + `SELECT e.id, e.description, e.event_date, e.km, + COALESCE(u.name, 'Gelöschter Benutzer') AS creator, a.id AS attachment_id, a.original_name AS attachment_name FROM maintenance_events e LEFT JOIN users u ON u.id = e.created_by @@ -119,7 +134,21 @@ export default async function MotorcycleDetailPage({ WHERE e.motorcycle_id = ? ORDER BY e.event_date DESC, e.id DESC`, ) - .all(motorcycleId) as EventRow[]; + .all(motorcycleId) as Omit[]; + const maintenanceItems = db + .prepare( + `SELECT item.id, item.maintenance_event_id, item.type, item.custom_type, + item.cost_cents, item.due_date, item.due_km + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + WHERE e.motorcycle_id = ? + ORDER BY e.event_date DESC, e.id DESC, item.position`, + ) + .all(motorcycleId) as MaintenanceItemRow[]; + const events: EventRow[] = eventRows.map((event) => ({ + ...event, + items: maintenanceItems.filter((item) => item.maintenance_event_id === event.id), + })); const trips = db .prepare( @@ -164,8 +193,8 @@ export default async function MotorcycleDetailPage({ .all(user.id, bike.created_by, motorcycleId) as ShareRow[]) : []; - const tuev = latestDue(events, "tuev_hu"); - const inspection = latestDue(events, "inspektion"); + const tuev = latestDue(maintenanceItems, "tuev_hu"); + const inspection = latestDue(maintenanceItems, "inspektion"); const tuevStatus = getDueStatus({ ...tuev, currentKm: bike.current_km }); const inspectionStatus = getDueStatus({ ...inspection, currentKm: bike.current_km }); @@ -174,15 +203,15 @@ export default async function MotorcycleDetailPage({
Übersicht / - {bike.nickname} + {formatMotorcycleName(bike.brand, bike.model)}
-

{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}

-

{bike.nickname}

+

Motorrad{bike.year ? ` · Baujahr ${bike.year}` : ""}

+

{formatMotorcycleName(bike.brand, bike.model)}

{bike.plate ?? "Ohne Kennzeichen"}

@@ -308,17 +337,18 @@ export default async function MotorcycleDetailPage({
- {maintenanceLabels[event.type]} - {event.title} + {event.items.map((item) => ( + + {maintenanceItemLabel(item)} + + ))}
- {event.description &&

{event.description}

}
- {formatDate(event.event_date)} + {formatDate(event.event_date)} {formatKm(event.km)} - {formatMoney(event.cost_cents)} - {(event.due_date || event.due_km != null) && ( - Nächste Fälligkeit: {formatDate(event.due_date)} · {formatKm(event.due_km)} - )} von {event.creator} {event.attachment_id && ( @@ -326,11 +356,44 @@ export default async function MotorcycleDetailPage({ )}
+ {event.description &&

{event.description}

} +
+ + + + + + + + + + {event.items.map((item) => ( + + + + + + ))} + +
ArtKostenNächste Fälligkeit
{maintenanceItemLabel(item)}{formatMoney(item.cost_cents)} + {item.due_date || item.due_km != null + ? `${formatDate(item.due_date)} · ${formatKm(item.due_km)}` + : "—"} +
+
+
+
+ + Bearbeiten + +
+ + +
-
- - -
)) )} @@ -374,10 +437,18 @@ export default async function MotorcycleDetailPage({ {formatKm(trip.end_km - trip.start_km)} {trip.purpose ?? "—"} -
- - -
+
+ + Bearbeiten + +
+ + +
+
))} diff --git a/src/app/(app)/motorcycles/[id]/trips/[tripId]/edit/page.tsx b/src/app/(app)/motorcycles/[id]/trips/[tripId]/edit/page.tsx new file mode 100644 index 0000000..ccb9a33 --- /dev/null +++ b/src/app/(app)/motorcycles/[id]/trips/[tripId]/edit/page.tsx @@ -0,0 +1,98 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Flash } from "@/components/flash"; +import { TripForm, type TripValue } from "@/components/trip-form"; +import { requireSessionUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { formatMotorcycleName, queryMessage } from "@/lib/format"; +import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; + +export const runtime = "nodejs"; + +type TripEditRow = TripValue & { + motorcycle_id: number; + motorcycle_brand: string; + motorcycle_model: string; +}; + +export default async function EditTripPage({ + params, + searchParams, +}: { + params: Promise<{ id: string; tripId: string }>; + searchParams: Promise>; +}) { + const user = await requireSessionUser(); + initDatabase(); + const { id, tripId } = await params; + const motorcycleId = Number(id); + const tripEntryId = Number(tripId); + if ( + !Number.isSafeInteger(motorcycleId) || + motorcycleId < 1 || + !Number.isSafeInteger(tripEntryId) || + tripEntryId < 1 + ) { + notFound(); + } + + const visible = motorcycleAccessFilter(user); + const row = db + .prepare( + `SELECT t.id, t.motorcycle_id, t.trip_date, t.driver_user_id, + t.start_km, t.end_km, t.purpose, t.notes, + m.brand AS motorcycle_brand, m.model AS motorcycle_model + FROM trips t + JOIN motorcycles m ON m.id = t.motorcycle_id + WHERE t.id = ? AND t.motorcycle_id = ? AND ${visible.clause}`, + ) + .get(tripEntryId, motorcycleId, ...visible.params) as TripEditRow | undefined; + if (!row) notFound(); + + const drivers = db + .prepare( + `SELECT id, name + FROM users + WHERE active = 1 OR id = ? + ORDER BY name COLLATE NOCASE`, + ) + .all(row.driver_user_id) as { id: number; name: string }[]; + const trip: TripValue = { + id: row.id, + trip_date: row.trip_date, + driver_user_id: row.driver_user_id, + start_km: row.start_km, + end_km: row.end_km, + purpose: row.purpose, + notes: row.notes, + }; + const params_ = await searchParams; + + return ( + <> +
+ Übersicht + / + + {formatMotorcycleName(row.motorcycle_brand, row.motorcycle_model)} + + / + Fahrt bearbeiten +
+
+
+

Fahrtenbuch

+

Fahrt bearbeiten

+
+
+ + + + ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 2ebfff8..98e70bb 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -4,7 +4,7 @@ import { InspectionSticker } from "@/components/inspection-sticker"; import { StatusPill } from "@/components/status-pill"; import { requireSessionUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; -import { formatDate, formatKm, queryMessage } from "@/lib/format"; +import { formatDate, formatKm, formatMotorcycleName, queryMessage } from "@/lib/format"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { getDueStatus } from "@/lib/status"; import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; @@ -13,7 +13,6 @@ export const runtime = "nodejs"; type BikeRow = { id: number; - nickname: string; brand: string; model: string; year: number | null; @@ -30,9 +29,10 @@ type BikeRow = { type DueRow = { id: number; motorcycle_id: number; - nickname: string; - type: MaintenanceType; - title: string; + brand: string; + model: string; + type: MaintenanceType | null; + custom_type: string | null; due_date: string | null; due_km: number | null; current_km: number; @@ -50,43 +50,52 @@ export default async function Dashboard({ const motorcycles = db .prepare( `SELECT m.*, owner.name AS owner_name, - (SELECT due_date FROM maintenance_events e - WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu' - AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) - ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS tuev_due_date, - (SELECT due_km FROM maintenance_events e - WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu' - AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) - ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS tuev_due_km, - (SELECT due_date FROM maintenance_events e - WHERE e.motorcycle_id = m.id AND e.type = 'inspektion' - AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) - ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_date, - (SELECT due_km FROM maintenance_events e - WHERE e.motorcycle_id = m.id AND e.type = 'inspektion' - AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) - ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km + (SELECT item.due_date + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + WHERE e.motorcycle_id = m.id AND item.type = 'tuev_hu' + ORDER BY e.event_date DESC, e.id DESC, item.position DESC LIMIT 1) AS tuev_due_date, + (SELECT item.due_km + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + WHERE e.motorcycle_id = m.id AND item.type = 'tuev_hu' + ORDER BY e.event_date DESC, e.id DESC, item.position DESC LIMIT 1) AS tuev_due_km, + (SELECT item.due_date + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + WHERE e.motorcycle_id = m.id AND item.type = 'inspektion' + ORDER BY e.event_date DESC, e.id DESC, item.position DESC LIMIT 1) AS inspection_due_date, + (SELECT item.due_km + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + WHERE e.motorcycle_id = m.id AND item.type = 'inspektion' + ORDER BY e.event_date DESC, e.id DESC, item.position DESC LIMIT 1) AS inspection_due_km FROM motorcycles m JOIN users owner ON owner.id = m.created_by WHERE ${visible.clause} - ORDER BY m.nickname COLLATE NOCASE`, + ORDER BY m.brand COLLATE NOCASE, m.model COLLATE NOCASE`, ) .all(...visible.params) as BikeRow[]; const dueRows = db .prepare( - `SELECT e.id, e.motorcycle_id, m.nickname, e.type, e.title, - e.due_date, e.due_km, m.current_km - FROM maintenance_events e - JOIN motorcycles m ON m.id = e.motorcycle_id - WHERE ${visible.clause} - AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) - AND e.id = ( - SELECT newer.id FROM maintenance_events newer - WHERE newer.motorcycle_id = e.motorcycle_id - AND newer.type = e.type - AND (newer.due_date IS NOT NULL OR newer.due_km IS NOT NULL) - ORDER BY newer.event_date DESC, newer.id DESC LIMIT 1 - )`, + `WITH ranked AS ( + SELECT item.id, e.motorcycle_id, m.brand, m.model, item.type, item.custom_type, + item.due_date, item.due_km, m.current_km, + ROW_NUMBER() OVER ( + PARTITION BY e.motorcycle_id, + COALESCE(item.type, 'custom:' || item.custom_type_key) + ORDER BY e.event_date DESC, e.id DESC, item.position DESC + ) AS row_number + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + JOIN motorcycles m ON m.id = e.motorcycle_id + WHERE ${visible.clause} + ) + SELECT id, motorcycle_id, brand, model, type, custom_type, + due_date, due_km, current_km + FROM ranked + WHERE row_number = 1 + AND (due_date IS NOT NULL OR due_km IS NOT NULL)`, ) .all(...visible.params) as DueRow[]; const dueItems = dueRows @@ -140,8 +149,8 @@ export default async function Dashboard({
-

{bike.brand} · {bike.model}

-

{bike.nickname}

+

Motorrad

+

{formatMotorcycleName(bike.brand, bike.model)}

{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}

Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name} @@ -180,8 +189,10 @@ export default async function Dashboard({ dueItems.map((item) => (

- {item.nickname} - {maintenanceLabels[item.type]} · {item.title} + {formatMotorcycleName(item.brand, item.model)} + + {item.type ? maintenanceLabels[item.type] : item.custom_type} +
{formatDate(item.due_date)} diff --git a/src/app/actions/database.ts b/src/app/actions/database.ts new file mode 100644 index 0000000..29717c1 --- /dev/null +++ b/src/app/actions/database.ts @@ -0,0 +1,114 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAdminUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; + +export type SqlConsoleState = { + status: "idle" | "success" | "error"; + message: string; + columns: string[]; + rows: string[][]; + truncated: boolean; +}; + +function sqlValue(value: unknown) { + if (value == null) return "NULL"; + if (value instanceof Uint8Array) return `[BLOB: ${value.byteLength} Bytes]`; + if (typeof value === "bigint") return value.toString(); + return String(value); +} + +function validateAdminSql(sql: string) { + if (!sql) throw new Error("Bitte einen SQL-Befehl eingeben."); + if (sql.length > 20_000) throw new Error("Der SQL-Befehl ist zu lang."); + if (/\b(ATTACH|DETACH)\b/i.test(sql)) { + throw new Error("ATTACH und DETACH sind aus Sicherheitsgründen gesperrt."); + } + if (/\bVACUUM\b[\s\S]*\bINTO\b/i.test(sql)) { + throw new Error("VACUUM INTO ist aus Sicherheitsgründen gesperrt."); + } + if (/\bload_extension\s*\(/i.test(sql)) { + throw new Error("Das Laden von SQLite-Erweiterungen ist gesperrt."); + } + if ( + /\bPRAGMA\b[\s\S]{0,100}\b(writable_schema|temp_store_directory|data_store_directory)\b/i.test(sql) + ) { + throw new Error("Dieses PRAGMA ist aus Sicherheitsgründen gesperrt."); + } + if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(sql)) { + throw new Error("Transaktionen werden pro SQL-Aufruf automatisch verwaltet."); + } +} + +function stripSqlComments(sql: string) { + return sql + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/--[^\r\n]*/g, " "); +} + +function isReadStatement(sql: string) { + const normalized = stripSqlComments(sql).trim(); + const keyword = normalized.match(/^([A-Za-z]+)/)?.[1]?.toUpperCase(); + if (keyword === "WITH") { + return !/\b(INSERT|UPDATE|DELETE|REPLACE)\b/i.test(normalized); + } + return keyword === "SELECT" || + keyword === "PRAGMA" || + keyword === "EXPLAIN" || + keyword === "VALUES"; +} + +export async function runDatabaseSqlAction( + _previousState: SqlConsoleState, + formData: FormData, +): Promise { + await requireAdminUser(); + initDatabase(); + + try { + const sql = String(formData.get("sql") ?? "").trim(); + validateAdminSql(sql); + const statement = db.prepare(sql); + const trailingSql = stripSqlComments(sql.slice(statement.sourceSQL.length)).trim(); + if (trailingSql) throw new Error("Bitte pro Aufruf nur einen SQL-Befehl ausführen."); + + let columns: string[] = []; + const rows: string[][] = []; + let truncated = false; + let message: string; + if (isReadStatement(sql)) { + for (const rawRow of statement.iterate() as Iterable>) { + if (columns.length === 0) columns = Object.keys(rawRow); + if (rows.length >= 200) { + truncated = true; + break; + } + rows.push(columns.map((column) => sqlValue(rawRow[column]))); + } + message = `${rows.length}${truncated ? "+" : ""} Zeile(n) ausgegeben.`; + } else { + const before = db.prepare("SELECT total_changes() AS count").get() as { count: number }; + statement.run(); + const after = db.prepare("SELECT total_changes() AS count").get() as { count: number }; + message = `${after.count - before.count} Zeile(n) geändert.`; + } + + revalidatePath("/admin/database"); + return { + status: "success", + message, + columns, + rows, + truncated, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : "SQL konnte nicht ausgeführt werden.", + columns: [], + rows: [], + truncated: false, + }; + } +} diff --git a/src/app/actions/motorcycles.ts b/src/app/actions/motorcycles.ts index 90d4a4c..81b4c14 100644 --- a/src/app/actions/motorcycles.ts +++ b/src/app/actions/motorcycles.ts @@ -13,10 +13,14 @@ import { decimal, idValue, integer, - maintenanceType, text, usernameValue, } from "@/lib/validation"; +import { + maintenanceLabels, + maintenanceTypes, + type MaintenanceType, +} from "@/lib/types"; const MAX_FILE_SIZE = 8 * 1024 * 1024; const MIME_EXTENSIONS: Record = { @@ -108,11 +112,13 @@ function maintenanceSpecsFields(formData: FormData) { function motorcycleFields(formData: FormData) { const maintenanceSpecs = maintenanceSpecsFields(formData); + const brand = text(formData, "brand", { required: true, max: 80 }); + const model = text(formData, "model", { required: true, max: 100 }); return { - nickname: text(formData, "nickname", { required: true, max: 80 }), - brand: text(formData, "brand", { required: true, max: 80 }), - model: text(formData, "model", { required: true, max: 100 }), + nickname: `${brand} ${model}`.slice(0, 80), + brand, + model, year: integer(formData, "year", { min: 1900, max: new Date().getFullYear() + 1 }), vin: text(formData, "vin", { max: 50 }) || null, plate: text(formData, "plate", { max: 30 }) || null, @@ -307,6 +313,105 @@ function hasExpectedSignature(bytes: Uint8Array, mime: string) { return false; } +function maintenanceEventFields(formData: FormData) { + const itemCount = integer(formData, "item_count", { required: true, min: 1, max: 30 }); + if (itemCount == null) throw new Error("Mindestens eine Wartungsart ist erforderlich."); + + const items = Array.from({ length: itemCount }, (_, index) => { + const rawType = text(formData, `item_type_${index}`, { required: true, max: 30 }); + const type = maintenanceTypes.includes(rawType as MaintenanceType) + ? rawType as MaintenanceType + : null; + if (!type && rawType !== "__custom__") throw new Error("Ungültige Wartungsart."); + const customType = type + ? null + : text(formData, `item_custom_type_${index}`, { required: true, max: 80 }); + + const costEuros = String(formData.get(`item_cost_euros_${index}`) ?? "") + .trim() + .replace(",", "."); + const costCents = costEuros ? Math.round(Number(costEuros) * 100) : null; + if (costCents != null && (!Number.isSafeInteger(costCents) || costCents < 0)) { + throw new Error(`Kosten in Zeile ${index + 1} sind ungültig.`); + } + + return { + type, + customType, + customTypeKey: customType?.toLocaleLowerCase("de-DE") ?? null, + costCents, + dueDate: dateValue(formData, `item_due_date_${index}`), + dueKm: integer(formData, `item_due_km_${index}`, { min: 0, max: 10_000_000 }), + }; + }); + + const itemKeys = items.map((item) => + item.type ?? `custom:${item.customType?.toLocaleLowerCase("de-DE")}`, + ); + if (new Set(itemKeys).size !== itemKeys.length) { + throw new Error("Jede Wartungsart darf pro Eintrag nur einmal vorkommen."); + } + + const costs = items.flatMap((item) => item.costCents == null ? [] : [item.costCents]); + return { + title: items + .map((item) => item.type ? maintenanceLabels[item.type] : item.customType) + .join(" + ") + .slice(0, 120), + description: text(formData, "description", { max: 5000 }) || null, + eventDate: dateValue(formData, "event_date", true), + km: integer(formData, "km", { min: 0, max: 10_000_000 }), + items, + legacyType: items.find((item) => item.type)?.type ?? "sonstiges", + totalCostCents: costs.length > 0 ? costs.reduce((sum, cost) => sum + cost, 0) : null, + legacyDueDate: items.find((item) => item.dueDate)?.dueDate ?? null, + legacyDueKm: items.find((item) => item.dueKm != null)?.dueKm ?? null, + }; +} + +function insertMaintenanceItems( + eventId: number, + items: ReturnType["items"], +) { + const insert = db.prepare( + `INSERT INTO maintenance_event_items + (maintenance_event_id, type, custom_type, custom_type_key, cost_cents, due_date, due_km, position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + items.forEach((item, position) => { + insert.run( + eventId, + item.type, + item.customType, + item.customTypeKey, + item.costCents, + item.dueDate, + item.dueKm, + position, + ); + }); +} + +async function storeAttachment(file: File | null) { + const upload = validateFile(file); + if (!upload) return null; + + const bytes = new Uint8Array(await upload.file.arrayBuffer()); + if (!hasExpectedSignature(bytes, upload.file.type)) { + throw new Error("Der Dateiinhalt entspricht nicht dem angegebenen Dateityp."); + } + const storedName = `${randomUUID()}${upload.extension}`; + const storedPath = join(getUploadDir(), storedName); + writeFileSync(storedPath, bytes, { flag: "wx" }); + return { + storedName, + storedPath, + originalName: basename(upload.file.name).replace(/[\u0000-\u001f]/g, "").slice(0, 180) || "Anhang", + mimeType: upload.file.type, + size: upload.file.size, + }; +} + export async function createMaintenanceAction(formData: FormData) { const user = await requireSessionUser(); initDatabase(); @@ -316,38 +421,9 @@ export async function createMaintenanceAction(formData: FormData) { try { motorcycleId = idValue(formData, "motorcycle_id"); ensureMotorcycleAccess(user, motorcycleId); - const type = maintenanceType(formData); - const title = text(formData, "title", { required: true, max: 120 }); - const description = text(formData, "description", { max: 5000 }) || null; - const eventDate = dateValue(formData, "event_date", true); - const km = integer(formData, "km", { min: 0, max: 10_000_000 }); - const costEuros = String(formData.get("cost_euros") ?? "").trim().replace(",", "."); - const costCents = costEuros ? Math.round(Number(costEuros) * 100) : null; - if (costCents != null && (!Number.isSafeInteger(costCents) || costCents < 0)) { - throw new Error("Kosten sind ungültig."); - } - const dueDate = dateValue(formData, "due_date"); - const dueKm = integer(formData, "due_km", { min: 0, max: 10_000_000 }); - const upload = validateFile(formData.get("attachment") as File | null); - - let attachment: - | { storedName: string; originalName: string; mimeType: string; size: number } - | null = null; - if (upload) { - const bytes = new Uint8Array(await upload.file.arrayBuffer()); - if (!hasExpectedSignature(bytes, upload.file.type)) { - throw new Error("Der Dateiinhalt entspricht nicht dem angegebenen Dateityp."); - } - const storedName = `${randomUUID()}${upload.extension}`; - storedPath = join(getUploadDir(), storedName); - writeFileSync(storedPath, bytes, { flag: "wx" }); - attachment = { - storedName, - originalName: basename(upload.file.name).replace(/[\u0000-\u001f]/g, "").slice(0, 180) || "Anhang", - mimeType: upload.file.type, - size: upload.file.size, - }; - } + const value = maintenanceEventFields(formData); + const attachment = await storeAttachment(formData.get("attachment") as File | null); + storedPath = attachment?.storedPath ?? null; transaction(() => { const result = db @@ -358,17 +434,18 @@ export async function createMaintenanceAction(formData: FormData) { ) .run( motorcycleId, - type, - title, - description, - eventDate, - km, - costCents, - dueDate, - dueKm, + value.legacyType, + value.title, + value.description, + value.eventDate, + value.km, + value.totalCostCents, + value.legacyDueDate, + value.legacyDueKm, user.id, ); const eventId = Number(result.lastInsertRowid); + insertMaintenanceItems(eventId, value.items); if (attachment) { db.prepare( `INSERT INTO attachments @@ -382,10 +459,10 @@ export async function createMaintenanceAction(formData: FormData) { attachment.size, ); } - if (km != null) { + if (value.km != null) { db.prepare( "UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?", - ).run(km, motorcycleId); + ).run(value.km, motorcycleId); } }); redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Wartungseintrag gespeichert.")}`); @@ -396,6 +473,141 @@ export async function createMaintenanceAction(formData: FormData) { } } +export async function updateMaintenanceAction(formData: FormData) { + const user = await requireSessionUser(); + initDatabase(); + let motorcycleId = 0; + let eventId = 0; + let storedPath: string | null = null; + + try { + eventId = idValue(formData); + const row = db + .prepare( + `SELECT m.motorcycle_id, a.stored_name + FROM maintenance_events m + LEFT JOIN attachments a ON a.maintenance_event_id = m.id + WHERE m.id = ?`, + ) + .get(eventId) as { motorcycle_id: number; stored_name: string | null } | undefined; + if (!row) throw new Error("Wartungseintrag wurde nicht gefunden."); + motorcycleId = row.motorcycle_id; + ensureMotorcycleAccess(user, motorcycleId); + + const value = maintenanceEventFields(formData); + const attachment = await storeAttachment(formData.get("attachment") as File | null); + storedPath = attachment?.storedPath ?? null; + + transaction(() => { + const result = db + .prepare( + `UPDATE maintenance_events + SET type = ?, title = ?, description = ?, event_date = ?, km = ?, + cost_cents = ?, due_date = ?, due_km = ? + WHERE id = ? AND motorcycle_id = ?`, + ) + .run( + value.legacyType, + value.title, + value.description, + value.eventDate, + value.km, + value.totalCostCents, + value.legacyDueDate, + value.legacyDueKm, + eventId, + motorcycleId, + ); + if (result.changes === 0) throw new Error("Wartungseintrag wurde nicht gefunden."); + + db.prepare("DELETE FROM maintenance_event_items WHERE maintenance_event_id = ?").run(eventId); + insertMaintenanceItems(eventId, value.items); + + if (attachment) { + db.prepare("DELETE FROM attachments WHERE maintenance_event_id = ?").run(eventId); + } + if (attachment) { + db.prepare( + `INSERT INTO attachments + (maintenance_event_id, stored_name, original_name, mime_type, size_bytes) + VALUES (?, ?, ?, ?, ?)`, + ).run( + eventId, + attachment.storedName, + attachment.originalName, + attachment.mimeType, + attachment.size, + ); + } + if (value.km != null) { + db.prepare( + "UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?", + ).run(value.km, motorcycleId); + } + }); + + storedPath = null; + if (attachment && row.stored_name) { + const oldPath = join(getUploadDir(), basename(row.stored_name)); + if (existsSync(oldPath)) unlinkSync(oldPath); + } + redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Wartungseintrag gespeichert.")}`); + } catch (error) { + unstable_rethrow(error); + if (storedPath && existsSync(storedPath)) unlinkSync(storedPath); + const path = + motorcycleId && eventId + ? `/motorcycles/${motorcycleId}/maintenance/${eventId}/edit` + : motorcycleId + ? `/motorcycles/${motorcycleId}` + : "/"; + go(path, "error", errorMessage(error)); + } +} + +export async function removeMaintenanceAttachmentAction(formData: FormData) { + const user = await requireSessionUser(); + initDatabase(); + let motorcycleId = 0; + let eventId = 0; + + try { + eventId = idValue(formData); + const row = db + .prepare( + `SELECT m.motorcycle_id, a.stored_name + FROM maintenance_events m + JOIN attachments a ON a.maintenance_event_id = m.id + WHERE m.id = ?`, + ) + .get(eventId) as { motorcycle_id: number; stored_name: string } | undefined; + if (!row) throw new Error("Anhang wurde nicht gefunden."); + motorcycleId = row.motorcycle_id; + ensureMotorcycleAccess(user, motorcycleId); + + const path = join(getUploadDir(), basename(row.stored_name)); + transaction(() => { + const result = db + .prepare("DELETE FROM attachments WHERE maintenance_event_id = ?") + .run(eventId); + if (result.changes === 0) throw new Error("Anhang wurde nicht gefunden."); + if (existsSync(path)) unlinkSync(path); + }); + redirect( + `/motorcycles/${motorcycleId}/maintenance/${eventId}/edit?success=${encodeURIComponent("Anhang entfernt.")}`, + ); + } catch (error) { + unstable_rethrow(error); + const path = + motorcycleId && eventId + ? `/motorcycles/${motorcycleId}/maintenance/${eventId}/edit` + : motorcycleId + ? `/motorcycles/${motorcycleId}` + : "/"; + go(path, "error", errorMessage(error)); + } +} + export async function deleteMaintenanceAction(formData: FormData) { const user = await requireSessionUser(); initDatabase(); @@ -425,6 +637,31 @@ export async function deleteMaintenanceAction(formData: FormData) { } } +function tripFields(formData: FormData, existingDriverUserId?: number) { + const driverUserId = integer(formData, "driver_user_id", { required: true, min: 1 }); + const startKm = integer(formData, "start_km", { required: true, min: 0, max: 10_000_000 }); + const endKm = integer(formData, "end_km", { required: true, min: 0, max: 10_000_000 }); + if ( + driverUserId == null || + !db + .prepare("SELECT 1 FROM users WHERE id = ? AND (active = 1 OR id = ?)") + .get(driverUserId, existingDriverUserId ?? 0) + ) { + throw new Error("Fahrer wurde nicht gefunden."); + } + if (startKm == null || endKm == null || endKm < startKm) { + throw new Error("Der Endkilometerstand muss mindestens dem Start entsprechen."); + } + return { + tripDate: dateValue(formData, "trip_date", true), + driverUserId, + startKm, + endKm, + purpose: text(formData, "purpose", { max: 160 }) || null, + notes: text(formData, "notes", { max: 2000 }) || null, + }; +} + export async function createTripAction(formData: FormData) { const user = await requireSessionUser(); initDatabase(); @@ -432,29 +669,23 @@ export async function createTripAction(formData: FormData) { try { motorcycleId = idValue(formData, "motorcycle_id"); ensureMotorcycleAccess(user, motorcycleId); - const tripDate = dateValue(formData, "trip_date", true); - const driverUserId = integer(formData, "driver_user_id", { required: true, min: 1 }); - const startKm = integer(formData, "start_km", { required: true, min: 0, max: 10_000_000 }); - const endKm = integer(formData, "end_km", { required: true, min: 0, max: 10_000_000 }); - if ( - driverUserId == null || - !db.prepare("SELECT 1 FROM users WHERE id = ? AND active = 1").get(driverUserId) - ) { - throw new Error("Fahrer wurde nicht gefunden."); - } - if (startKm == null || endKm == null || endKm < startKm) { - throw new Error("Der Endkilometerstand muss mindestens dem Start entsprechen."); - } - const purpose = text(formData, "purpose", { max: 160 }) || null; - const notes = text(formData, "notes", { max: 2000 }) || null; + const value = tripFields(formData); transaction(() => { db.prepare( `INSERT INTO trips (motorcycle_id, trip_date, driver_user_id, start_km, end_km, purpose, notes) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run(motorcycleId, tripDate, driverUserId, startKm, endKm, purpose, notes); + ).run( + motorcycleId, + value.tripDate, + value.driverUserId, + value.startKm, + value.endKm, + value.purpose, + value.notes, + ); db.prepare("UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?").run( - endKm, + value.endKm, motorcycleId, ); }); @@ -465,6 +696,59 @@ export async function createTripAction(formData: FormData) { } } +export async function updateTripAction(formData: FormData) { + const user = await requireSessionUser(); + initDatabase(); + let motorcycleId = 0; + let tripId = 0; + try { + tripId = idValue(formData); + const trip = db + .prepare("SELECT motorcycle_id, driver_user_id FROM trips WHERE id = ?") + .get(tripId) as { motorcycle_id: number; driver_user_id: number } | undefined; + if (!trip) throw new Error("Fahrt wurde nicht gefunden."); + motorcycleId = trip.motorcycle_id; + ensureMotorcycleAccess(user, motorcycleId); + const value = tripFields(formData, trip.driver_user_id); + + transaction(() => { + const result = db + .prepare( + `UPDATE trips + SET trip_date = ?, driver_user_id = ?, start_km = ?, end_km = ?, + purpose = ?, notes = ? + WHERE id = ? AND motorcycle_id = ?`, + ) + .run( + value.tripDate, + value.driverUserId, + value.startKm, + value.endKm, + value.purpose, + value.notes, + tripId, + motorcycleId, + ); + if (result.changes === 0) throw new Error("Fahrt wurde nicht gefunden."); + db.prepare("UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?").run( + value.endKm, + motorcycleId, + ); + }); + + redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gespeichert.")}`); + } catch (error) { + unstable_rethrow(error); + const path = + motorcycleId && tripId + ? `/motorcycles/${motorcycleId}/trips/${tripId}/edit` + : motorcycleId + ? `/motorcycles/${motorcycleId}` + : "/"; + go(path, "error", errorMessage(error)); + } +} + export async function deleteTripAction(formData: FormData) { const user = await requireSessionUser(); initDatabase(); diff --git a/src/app/globals.css b/src/app/globals.css index 73954b1..32c4364 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -356,6 +356,21 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); } .form-actions { display: flex; justify-content: flex-end; } +.maintenance-items { + border-top: 1px solid var(--paper-line); + border-bottom: 1px solid var(--paper-line); + padding: 1rem 0; +} + +.maintenance-items .section-heading { margin-bottom: 0.8rem; } +.maintenance-items-table { min-width: 780px; } +.maintenance-items-table th:first-child, +.maintenance-items-table td:first-child { min-width: 190px; } +.maintenance-items-table input, +.maintenance-items-table select { min-width: 120px; } +.maintenance-items-table td:first-child input { margin-top: 0.4rem; } +.maintenance-history-table { margin: 0.35rem 0; min-width: 480px; } + /* ---------- Bike grid & cards ---------- */ .bike-grid { display: grid; @@ -584,6 +599,60 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); } .data-table td { padding: 0.6rem 0.7rem; border-bottom: 1px solid var(--paper-line); } .data-table tr:last-child td { border-bottom: none; } +.db-admin-layout { + display: grid; + grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.db-object-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + position: sticky; + top: 82px; + max-height: calc(100vh - 110px); + overflow-y: auto; +} + +.db-object-list a { + display: flex; + flex-direction: column; + padding: 0.55rem 0.65rem; + border-radius: 6px; + text-decoration: none; +} + +.db-object-list a:hover, +.db-object-list a.active { background: var(--paper); } +.db-object-list a.active { box-shadow: inset 3px 0 var(--signal-dark); } +.db-object-list small { color: var(--steel); } +.db-admin-content { min-width: 0; } +.schema-sql { + margin: 1rem 0 0; + padding: 0.8rem; + background: var(--ink); + color: var(--paper-2); + border-radius: 7px; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 260px; + overflow: auto; +} + +.db-row-preview td, +.sql-result td { + max-width: 360px; + overflow-wrap: anywhere; + vertical-align: top; +} + +.sql-editor { + tab-size: 2; + min-height: 180px; +} + .inline-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; } .inline-form select { width: auto; } @@ -608,6 +677,8 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); } /* ---------- Responsive ---------- */ @media (max-width: 780px) { + .db-admin-layout { grid-template-columns: 1fr; } + .db-object-list { position: static; max-height: 300px; } .detail-top { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; } .form-grid .span-2 { grid-column: auto; } diff --git a/src/app/kiosk/page.tsx b/src/app/kiosk/page.tsx index dd64184..f496185 100644 --- a/src/app/kiosk/page.tsx +++ b/src/app/kiosk/page.tsx @@ -1,12 +1,12 @@ import Link from "next/link"; import { db, initDatabase } from "@/lib/db"; +import { formatMotorcycleName } from "@/lib/format"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; type MotorcycleMaintenanceRow = { id: number; - nickname: string; brand: string; model: string; year: number | null; @@ -104,7 +104,7 @@ export default async function KioskPage({ const matches = plate && !plateError ? (db .prepare( - `SELECT m.id, m.nickname, m.brand, m.model, m.year, m.plate, owner.name AS owner_name, + `SELECT m.id, m.brand, m.model, m.year, m.plate, owner.name AS owner_name, m.tire_pressure_front_bar, m.tire_pressure_rear_bar, m.chain_tension_min_mm, m.chain_tension_max_mm, m.oil_type, m.oil_capacity_liters, m.oil_check_temp, m.oil_check_run_minutes, m.oil_check_wait_min_minutes, m.oil_check_wait_max_minutes, m.oil_check_method @@ -175,8 +175,8 @@ export default async function KioskPage({ <>
-

{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}

-

{bike.nickname}

+

Motorrad{bike.year ? ` · Baujahr ${bike.year}` : ""}

+

{formatMotorcycleName(bike.brand, bike.model)}

Kennzeichen: {bike.plate ?? "—"} · Besitzer: {bike.owner_name}

diff --git a/src/components/database-console.tsx b/src/components/database-console.tsx new file mode 100644 index 0000000..40da464 --- /dev/null +++ b/src/components/database-console.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useActionState } from "react"; +import { + runDatabaseSqlAction, + type SqlConsoleState, +} from "@/app/actions/database"; + +const DEFAULT_SQL = "SELECT name, type, sql\nFROM sqlite_schema\nORDER BY type, name;"; +const INITIAL_STATE: SqlConsoleState = { + status: "idle", + message: "", + columns: [], + rows: [], + truncated: false, +}; + +export function DatabaseConsole() { + const [state, formAction, pending] = useActionState( + runDatabaseSqlAction, + INITIAL_STATE, + ); + + return ( +
+
+
+

Direktzugriff

+

SQL-Konsole

+
+
+
+