Add SQL management UI, maintenance, and trip edit pages for motorcycles

This commit is contained in:
2026-07-27 21:18:28 +02:00
parent 7487a7d588
commit 1218c13660
21 changed files with 1562 additions and 212 deletions
+212
View File
@@ -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<Record<string, string | string[] | undefined>>;
}) {
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<DatabaseObject, "rowCount">[]).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<string, unknown>[] = [];
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<string, unknown>[];
}
return (
<>
<div className="breadcrumbs">
<Link href="/">Übersicht</Link>
<span>/</span>
Datenbank
</div>
<div className="page-heading">
<div>
<p className="eyebrow">Administration</p>
<h1>Datenbankverwaltung</h1>
<p className="muted">
Vollzugriff auf Tabellen, Ansichten und SQL-Befehle. Änderungen wirken sofort.
</p>
</div>
</div>
<div className="db-admin-layout">
<aside className="panel db-object-list">
<h3 className="panel-title">Tabellen & Ansichten</h3>
{objects.map((object) => (
<Link
className={selected?.name === object.name ? "active" : ""}
href={`/admin/database?table=${encodeURIComponent(object.name)}`}
key={`${object.type}-${object.name}`}
>
<span>{object.name}</span>
<small>{object.type === "table" ? "Tabelle" : "Ansicht"} · {object.rowCount}</small>
</Link>
))}
</aside>
<div className="db-admin-content">
{selected && (
<>
<section className="panel">
<p className="eyebrow">{selected.type === "table" ? "Tabelle" : "Ansicht"}</p>
<h2>{selected.name}</h2>
<pre className="schema-sql mono">{selected.sql ?? "Kein Schema verfügbar."}</pre>
</section>
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Struktur</p>
<h2>Spalten</h2>
</div>
</div>
<div className="panel table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Pflicht</th>
<th>Standard</th>
<th>Primärschlüssel</th>
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.cid}>
<td className="mono">{column.name}</td>
<td>{column.type || "—"}</td>
<td>{column.notnull ? "Ja" : "Nein"}</td>
<td className="mono">{column.dflt_value ?? "—"}</td>
<td>{column.pk ? `Position ${column.pk}` : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Daten</p>
<h2>{selected.rowCount} Datensätze</h2>
</div>
{totalPages > 1 && (
<div className="heading-actions">
{currentPage > 1 && (
<Link
className="button"
href={`/admin/database?table=${encodeURIComponent(selected.name)}&page=${currentPage - 1}`}
>
Zurück
</Link>
)}
<span>Seite {currentPage} / {totalPages}</span>
{currentPage < totalPages && (
<Link
className="button"
href={`/admin/database?table=${encodeURIComponent(selected.name)}&page=${currentPage + 1}`}
>
Weiter
</Link>
)}
</div>
)}
</div>
<div className="panel table-wrap db-row-preview">
{rows.length === 0 ? (
<p className="muted">Keine Datensätze vorhanden.</p>
) : (
<table className="data-table">
<thead>
<tr>
{Object.keys(rows[0]).map((column) => <th key={column}>{column}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{Object.entries(row).map(([column, value]) => (
<td className="mono" key={column}>{displayValue(value)}</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</>
)}
</div>
</div>
<DatabaseConsole />
</>
);
}
+6 -1
View File
@@ -19,7 +19,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<nav aria-label="Hauptnavigation"> <nav aria-label="Hauptnavigation">
<Link href="/">Übersicht</Link> <Link href="/">Übersicht</Link>
<Link href="/motorcycles/new">Motorrad +</Link> <Link href="/motorcycles/new">Motorrad +</Link>
{user.role === "admin" && <Link href="/admin/users">Benutzer</Link>} {user.role === "admin" && (
<>
<Link href="/admin/users">Benutzer</Link>
<Link href="/admin/database">Datenbank</Link>
</>
)}
<Link href="/account">Konto</Link> <Link href="/account">Konto</Link>
</nav> </nav>
<div className="user-menu"> <div className="user-menu">
+4 -4
View File
@@ -4,14 +4,13 @@ import { Flash } from "@/components/flash";
import { MotorcycleForm } from "@/components/motorcycle-form"; import { MotorcycleForm } from "@/components/motorcycle-form";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; import { db, initDatabase } from "@/lib/db";
import { queryMessage } from "@/lib/format"; import { formatMotorcycleName, queryMessage } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs"; export const runtime = "nodejs";
type MotorcycleValue = { type MotorcycleValue = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -52,7 +51,6 @@ export default async function EditMotorcyclePage({
if (!bike) notFound(); if (!bike) notFound();
const motorcycleForForm: MotorcycleValue = { const motorcycleForForm: MotorcycleValue = {
id: bike.id, id: bike.id,
nickname: bike.nickname,
brand: bike.brand, brand: bike.brand,
model: bike.model, model: bike.model,
year: bike.year, year: bike.year,
@@ -80,7 +78,9 @@ export default async function EditMotorcyclePage({
<div className="breadcrumbs"> <div className="breadcrumbs">
<Link href="/">Übersicht</Link> <Link href="/">Übersicht</Link>
<span>/</span> <span>/</span>
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link> <Link href={`/motorcycles/${bike.id}`}>
{formatMotorcycleName(bike.brand, bike.model)}
</Link>
<span>/</span> <span>/</span>
Bearbeiten Bearbeiten
</div> </div>
@@ -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<MaintenanceValue, "items"> & {
motorcycle_id: number;
motorcycle_brand: string;
motorcycle_model: string;
};
export default async function EditMaintenancePage({
params,
searchParams,
}: {
params: Promise<{ id: string; eventId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
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 (
<>
<div className="breadcrumbs">
<Link href="/">Übersicht</Link>
<span>/</span>
<Link href={`/motorcycles/${maintenanceRow.motorcycle_id}`}>
{formatMotorcycleName(
maintenanceRow.motorcycle_brand,
maintenanceRow.motorcycle_model,
)}
</Link>
<span>/</span>
Wartungseintrag bearbeiten
</div>
<div className="page-heading">
<div>
<p className="eyebrow">Werkstattbuch</p>
<h1>Wartungseintrag bearbeiten</h1>
</div>
</div>
<Flash
error={queryMessage(params_, "error")}
success={queryMessage(params_, "success")}
/>
<MaintenanceForm
motorcycleId={maintenanceRow.motorcycle_id}
maintenance={maintenance}
/>
</>
);
}
@@ -4,14 +4,13 @@ import { Flash } from "@/components/flash";
import { MaintenanceSpecsForm } from "@/components/maintenance-specs-form"; import { MaintenanceSpecsForm } from "@/components/maintenance-specs-form";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; import { db, initDatabase } from "@/lib/db";
import { queryMessage } from "@/lib/format"; import { formatMotorcycleName, queryMessage } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs"; export const runtime = "nodejs";
type MaintenanceEditRow = { type MaintenanceEditRow = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -44,7 +43,7 @@ export default async function MotorcycleMaintenanceEditPage({
const visible = motorcycleAccessFilter(user); const visible = motorcycleAccessFilter(user);
const bike = db const bike = db
.prepare( .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, tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
oil_type, oil_capacity_liters, 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 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({
<div className="breadcrumbs"> <div className="breadcrumbs">
<Link href="/">Übersicht</Link> <Link href="/">Übersicht</Link>
<span>/</span> <span>/</span>
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link> <Link href={`/motorcycles/${bike.id}`}>
{formatMotorcycleName(bike.brand, bike.model)}
</Link>
<span>/</span> <span>/</span>
<Link href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link> <Link href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link>
<span>/</span> <span>/</span>
@@ -2,13 +2,13 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; import { db, initDatabase } from "@/lib/db";
import { formatMotorcycleName } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs"; export const runtime = "nodejs";
type MotorcycleMaintenanceRow = { type MotorcycleMaintenanceRow = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -108,7 +108,7 @@ export default async function MotorcycleMaintenancePage({
const visible = motorcycleAccessFilter(user); const visible = motorcycleAccessFilter(user);
const bike = db const bike = db
.prepare( .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, tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
oil_type, oil_capacity_liters, 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 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({
<div className="breadcrumbs"> <div className="breadcrumbs">
<Link href="/">Übersicht</Link> <Link href="/">Übersicht</Link>
<span>/</span> <span>/</span>
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link> <Link href={`/motorcycles/${bike.id}`}>
{formatMotorcycleName(bike.brand, bike.model)}
</Link>
<span>/</span> <span>/</span>
Wartungsansicht Wartungsansicht
</div> </div>
+105 -34
View File
@@ -14,7 +14,13 @@ import { StatusPill } from "@/components/status-pill";
import { TripForm } from "@/components/trip-form"; import { TripForm } from "@/components/trip-form";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; 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 { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status"; import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
@@ -23,7 +29,6 @@ export const runtime = "nodejs";
type MotorcycleRow = { type MotorcycleRow = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -48,17 +53,23 @@ type MotorcycleRow = {
type EventRow = { type EventRow = {
id: number; id: number;
type: MaintenanceType;
title: string;
description: string | null; description: string | null;
event_date: string; event_date: string;
km: number | null; km: number | null;
cost_cents: number | null;
due_date: string | null;
due_km: number | null;
creator: string; creator: string;
attachment_id: number | null; attachment_id: number | null;
attachment_name: string | 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 = { type TripRow = {
@@ -76,11 +87,15 @@ type ShareRow = {
name: string; name: string;
}; };
function latestDue(events: EventRow[], type: MaintenanceType) { function latestDue(items: MaintenanceItemRow[], type: MaintenanceType) {
const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null)); const match = items.find((item) => item.type === type);
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null }; 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({ export default async function MotorcycleDetailPage({
params, params,
searchParams, searchParams,
@@ -108,10 +123,10 @@ export default async function MotorcycleDetailPage({
const params_ = await searchParams; const params_ = await searchParams;
const events = db const eventRows = db
.prepare( .prepare(
`SELECT e.id, e.type, e.title, e.description, e.event_date, e.km, e.cost_cents, `SELECT e.id, e.description, e.event_date, e.km,
e.due_date, e.due_km, COALESCE(u.name, 'Gelöschter Benutzer') AS creator, COALESCE(u.name, 'Gelöschter Benutzer') AS creator,
a.id AS attachment_id, a.original_name AS attachment_name a.id AS attachment_id, a.original_name AS attachment_name
FROM maintenance_events e FROM maintenance_events e
LEFT JOIN users u ON u.id = e.created_by LEFT JOIN users u ON u.id = e.created_by
@@ -119,7 +134,21 @@ export default async function MotorcycleDetailPage({
WHERE e.motorcycle_id = ? WHERE e.motorcycle_id = ?
ORDER BY e.event_date DESC, e.id DESC`, ORDER BY e.event_date DESC, e.id DESC`,
) )
.all(motorcycleId) as EventRow[]; .all(motorcycleId) as Omit<EventRow, "items">[];
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 const trips = db
.prepare( .prepare(
@@ -164,8 +193,8 @@ export default async function MotorcycleDetailPage({
.all(user.id, bike.created_by, motorcycleId) as ShareRow[]) .all(user.id, bike.created_by, motorcycleId) as ShareRow[])
: []; : [];
const tuev = latestDue(events, "tuev_hu"); const tuev = latestDue(maintenanceItems, "tuev_hu");
const inspection = latestDue(events, "inspektion"); const inspection = latestDue(maintenanceItems, "inspektion");
const tuevStatus = getDueStatus({ ...tuev, currentKm: bike.current_km }); const tuevStatus = getDueStatus({ ...tuev, currentKm: bike.current_km });
const inspectionStatus = getDueStatus({ ...inspection, currentKm: bike.current_km }); const inspectionStatus = getDueStatus({ ...inspection, currentKm: bike.current_km });
@@ -174,15 +203,15 @@ export default async function MotorcycleDetailPage({
<div className="breadcrumbs"> <div className="breadcrumbs">
<Link href="/">Übersicht</Link> <Link href="/">Übersicht</Link>
<span>/</span> <span>/</span>
{bike.nickname} {formatMotorcycleName(bike.brand, bike.model)}
</div> </div>
<Flash error={queryMessage(params_, "error")} success={queryMessage(params_, "success")} /> <Flash error={queryMessage(params_, "error")} success={queryMessage(params_, "success")} />
<div className="page-heading detail-heading"> <div className="page-heading detail-heading">
<div> <div>
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p> <p className="eyebrow">Motorrad{bike.year ? ` · Baujahr ${bike.year}` : ""}</p>
<h1>{bike.nickname}</h1> <h1>{formatMotorcycleName(bike.brand, bike.model)}</h1>
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p> <p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
</div> </div>
<div className="heading-actions"> <div className="heading-actions">
@@ -308,17 +337,18 @@ export default async function MotorcycleDetailPage({
<article className="timeline-item" key={event.id}> <article className="timeline-item" key={event.id}>
<div className="timeline-main"> <div className="timeline-main">
<div className="timeline-head"> <div className="timeline-head">
<span className={`type-tag type-${event.type}`}>{maintenanceLabels[event.type]}</span> {event.items.map((item) => (
<strong>{event.title}</strong> <span
className={`type-tag type-${item.type ?? "sonstiges"}`}
key={item.id}
>
{maintenanceItemLabel(item)}
</span>
))}
</div> </div>
{event.description && <p className="muted">{event.description}</p>}
<div className="timeline-meta"> <div className="timeline-meta">
<span>{formatDate(event.event_date)}</span> <strong>{formatDate(event.event_date)}</strong>
<span>{formatKm(event.km)}</span> <span>{formatKm(event.km)}</span>
<span>{formatMoney(event.cost_cents)}</span>
{(event.due_date || event.due_km != null) && (
<span>Nächste Fälligkeit: {formatDate(event.due_date)} · {formatKm(event.due_km)}</span>
)}
<span>von {event.creator}</span> <span>von {event.creator}</span>
{event.attachment_id && ( {event.attachment_id && (
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer"> <a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
@@ -326,11 +356,44 @@ export default async function MotorcycleDetailPage({
</a> </a>
)} )}
</div> </div>
{event.description && <p className="notes">{event.description}</p>}
<div className="table-wrap">
<table className="data-table maintenance-history-table">
<thead>
<tr>
<th>Art</th>
<th>Kosten</th>
<th>Nächste Fälligkeit</th>
</tr>
</thead>
<tbody>
{event.items.map((item) => (
<tr key={item.id}>
<td>{maintenanceItemLabel(item)}</td>
<td>{formatMoney(item.cost_cents)}</td>
<td>
{item.due_date || item.due_km != null
? `${formatDate(item.due_date)} · ${formatKm(item.due_km)}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="inline-form">
<Link
className="link-button"
href={`/motorcycles/${bike.id}/maintenance/${event.id}/edit`}
>
Bearbeiten
</Link>
<form action={deleteMaintenanceAction} className="inline-form">
<input type="hidden" name="id" value={event.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</div> </div>
<form action={deleteMaintenanceAction} className="inline-form">
<input type="hidden" name="id" value={event.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</article> </article>
)) ))
)} )}
@@ -374,10 +437,18 @@ export default async function MotorcycleDetailPage({
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td> <td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
<td>{trip.purpose ?? "—"}</td> <td>{trip.purpose ?? "—"}</td>
<td> <td>
<form action={deleteTripAction} className="inline-form"> <div className="inline-form">
<input type="hidden" name="id" value={trip.id} /> <Link
<button className="link-button danger" type="submit">Löschen</button> className="link-button"
</form> href={`/motorcycles/${bike.id}/trips/${trip.id}/edit`}
>
Bearbeiten
</Link>
<form action={deleteTripAction} className="inline-form">
<input type="hidden" name="id" value={trip.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -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<Record<string, string | string[] | undefined>>;
}) {
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 (
<>
<div className="breadcrumbs">
<Link href="/">Übersicht</Link>
<span>/</span>
<Link href={`/motorcycles/${row.motorcycle_id}`}>
{formatMotorcycleName(row.motorcycle_brand, row.motorcycle_model)}
</Link>
<span>/</span>
Fahrt bearbeiten
</div>
<div className="page-heading">
<div>
<p className="eyebrow">Fahrtenbuch</p>
<h1>Fahrt bearbeiten</h1>
</div>
</div>
<Flash error={queryMessage(params_, "error")} />
<TripForm
motorcycleId={row.motorcycle_id}
currentKm={row.end_km}
drivers={drivers}
currentUserId={user.id}
trip={trip}
/>
</>
);
}
+50 -39
View File
@@ -4,7 +4,7 @@ import { InspectionSticker } from "@/components/inspection-sticker";
import { StatusPill } from "@/components/status-pill"; import { StatusPill } from "@/components/status-pill";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; 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 { motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status"; import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
@@ -13,7 +13,6 @@ export const runtime = "nodejs";
type BikeRow = { type BikeRow = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -30,9 +29,10 @@ type BikeRow = {
type DueRow = { type DueRow = {
id: number; id: number;
motorcycle_id: number; motorcycle_id: number;
nickname: string; brand: string;
type: MaintenanceType; model: string;
title: string; type: MaintenanceType | null;
custom_type: string | null;
due_date: string | null; due_date: string | null;
due_km: number | null; due_km: number | null;
current_km: number; current_km: number;
@@ -50,43 +50,52 @@ export default async function Dashboard({
const motorcycles = db const motorcycles = db
.prepare( .prepare(
`SELECT m.*, owner.name AS owner_name, `SELECT m.*, owner.name AS owner_name,
(SELECT due_date FROM maintenance_events e (SELECT item.due_date
WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu' FROM maintenance_event_items item
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) JOIN maintenance_events e ON e.id = item.maintenance_event_id
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS tuev_due_date, WHERE e.motorcycle_id = m.id AND item.type = 'tuev_hu'
(SELECT due_km FROM maintenance_events e ORDER BY e.event_date DESC, e.id DESC, item.position DESC LIMIT 1) AS tuev_due_date,
WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu' (SELECT item.due_km
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) FROM maintenance_event_items item
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS tuev_due_km, JOIN maintenance_events e ON e.id = item.maintenance_event_id
(SELECT due_date FROM maintenance_events e WHERE e.motorcycle_id = m.id AND item.type = 'tuev_hu'
WHERE e.motorcycle_id = m.id AND e.type = 'inspektion' ORDER BY e.event_date DESC, e.id DESC, item.position DESC LIMIT 1) AS tuev_due_km,
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) (SELECT item.due_date
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_date, FROM maintenance_event_items item
(SELECT due_km FROM maintenance_events e JOIN maintenance_events e ON e.id = item.maintenance_event_id
WHERE e.motorcycle_id = m.id AND e.type = 'inspektion' WHERE e.motorcycle_id = m.id AND item.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, item.position DESC LIMIT 1) AS inspection_due_date,
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km (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 FROM motorcycles m
JOIN users owner ON owner.id = m.created_by JOIN users owner ON owner.id = m.created_by
WHERE ${visible.clause} WHERE ${visible.clause}
ORDER BY m.nickname COLLATE NOCASE`, ORDER BY m.brand COLLATE NOCASE, m.model COLLATE NOCASE`,
) )
.all(...visible.params) as BikeRow[]; .all(...visible.params) as BikeRow[];
const dueRows = db const dueRows = db
.prepare( .prepare(
`SELECT e.id, e.motorcycle_id, m.nickname, e.type, e.title, `WITH ranked AS (
e.due_date, e.due_km, m.current_km SELECT item.id, e.motorcycle_id, m.brand, m.model, item.type, item.custom_type,
FROM maintenance_events e item.due_date, item.due_km, m.current_km,
JOIN motorcycles m ON m.id = e.motorcycle_id ROW_NUMBER() OVER (
WHERE ${visible.clause} PARTITION BY e.motorcycle_id,
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL) COALESCE(item.type, 'custom:' || item.custom_type_key)
AND e.id = ( ORDER BY e.event_date DESC, e.id DESC, item.position DESC
SELECT newer.id FROM maintenance_events newer ) AS row_number
WHERE newer.motorcycle_id = e.motorcycle_id FROM maintenance_event_items item
AND newer.type = e.type JOIN maintenance_events e ON e.id = item.maintenance_event_id
AND (newer.due_date IS NOT NULL OR newer.due_km IS NOT NULL) JOIN motorcycles m ON m.id = e.motorcycle_id
ORDER BY newer.event_date DESC, newer.id DESC LIMIT 1 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[]; .all(...visible.params) as DueRow[];
const dueItems = dueRows const dueItems = dueRows
@@ -140,8 +149,8 @@ export default async function Dashboard({
<Link className="bike-card" href={`/motorcycles/${bike.id}`} key={bike.id}> <Link className="bike-card" href={`/motorcycles/${bike.id}`} key={bike.id}>
<div className="bike-card-top"> <div className="bike-card-top">
<div> <div>
<p className="eyebrow">{bike.brand} · {bike.model}</p> <p className="eyebrow">Motorrad</p>
<h2>{bike.nickname}</h2> <h2>{formatMotorcycleName(bike.brand, bike.model)}</h2>
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p> <p className="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p>
<p className="muted"> <p className="muted">
Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name} Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name}
@@ -180,8 +189,10 @@ export default async function Dashboard({
dueItems.map((item) => ( dueItems.map((item) => (
<Link href={`/motorcycles/${item.motorcycle_id}`} className="due-item" key={item.id}> <Link href={`/motorcycles/${item.motorcycle_id}`} className="due-item" key={item.id}>
<div> <div>
<strong>{item.nickname}</strong> <strong>{formatMotorcycleName(item.brand, item.model)}</strong>
<span>{maintenanceLabels[item.type]} · {item.title}</span> <span>
{item.type ? maintenanceLabels[item.type] : item.custom_type}
</span>
</div> </div>
<div className="due-figures"> <div className="due-figures">
<span>{formatDate(item.due_date)}</span> <span>{formatDate(item.due_date)}</span>
+114
View File
@@ -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<SqlConsoleState> {
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<Record<string, unknown>>) {
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,
};
}
}
+347 -63
View File
@@ -13,10 +13,14 @@ import {
decimal, decimal,
idValue, idValue,
integer, integer,
maintenanceType,
text, text,
usernameValue, usernameValue,
} from "@/lib/validation"; } from "@/lib/validation";
import {
maintenanceLabels,
maintenanceTypes,
type MaintenanceType,
} from "@/lib/types";
const MAX_FILE_SIZE = 8 * 1024 * 1024; const MAX_FILE_SIZE = 8 * 1024 * 1024;
const MIME_EXTENSIONS: Record<string, string> = { const MIME_EXTENSIONS: Record<string, string> = {
@@ -108,11 +112,13 @@ function maintenanceSpecsFields(formData: FormData) {
function motorcycleFields(formData: FormData) { function motorcycleFields(formData: FormData) {
const maintenanceSpecs = maintenanceSpecsFields(formData); const maintenanceSpecs = maintenanceSpecsFields(formData);
const brand = text(formData, "brand", { required: true, max: 80 });
const model = text(formData, "model", { required: true, max: 100 });
return { return {
nickname: text(formData, "nickname", { required: true, max: 80 }), nickname: `${brand} ${model}`.slice(0, 80),
brand: text(formData, "brand", { required: true, max: 80 }), brand,
model: text(formData, "model", { required: true, max: 100 }), model,
year: integer(formData, "year", { min: 1900, max: new Date().getFullYear() + 1 }), year: integer(formData, "year", { min: 1900, max: new Date().getFullYear() + 1 }),
vin: text(formData, "vin", { max: 50 }) || null, vin: text(formData, "vin", { max: 50 }) || null,
plate: text(formData, "plate", { max: 30 }) || null, plate: text(formData, "plate", { max: 30 }) || null,
@@ -307,6 +313,105 @@ function hasExpectedSignature(bytes: Uint8Array, mime: string) {
return false; 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<typeof maintenanceEventFields>["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) { export async function createMaintenanceAction(formData: FormData) {
const user = await requireSessionUser(); const user = await requireSessionUser();
initDatabase(); initDatabase();
@@ -316,38 +421,9 @@ export async function createMaintenanceAction(formData: FormData) {
try { try {
motorcycleId = idValue(formData, "motorcycle_id"); motorcycleId = idValue(formData, "motorcycle_id");
ensureMotorcycleAccess(user, motorcycleId); ensureMotorcycleAccess(user, motorcycleId);
const type = maintenanceType(formData); const value = maintenanceEventFields(formData);
const title = text(formData, "title", { required: true, max: 120 }); const attachment = await storeAttachment(formData.get("attachment") as File | null);
const description = text(formData, "description", { max: 5000 }) || null; storedPath = attachment?.storedPath ?? 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,
};
}
transaction(() => { transaction(() => {
const result = db const result = db
@@ -358,17 +434,18 @@ export async function createMaintenanceAction(formData: FormData) {
) )
.run( .run(
motorcycleId, motorcycleId,
type, value.legacyType,
title, value.title,
description, value.description,
eventDate, value.eventDate,
km, value.km,
costCents, value.totalCostCents,
dueDate, value.legacyDueDate,
dueKm, value.legacyDueKm,
user.id, user.id,
); );
const eventId = Number(result.lastInsertRowid); const eventId = Number(result.lastInsertRowid);
insertMaintenanceItems(eventId, value.items);
if (attachment) { if (attachment) {
db.prepare( db.prepare(
`INSERT INTO attachments `INSERT INTO attachments
@@ -382,10 +459,10 @@ export async function createMaintenanceAction(formData: FormData) {
attachment.size, attachment.size,
); );
} }
if (km != null) { if (value.km != null) {
db.prepare( db.prepare(
"UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?", "UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?",
).run(km, motorcycleId); ).run(value.km, motorcycleId);
} }
}); });
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Wartungseintrag gespeichert.")}`); 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) { export async function deleteMaintenanceAction(formData: FormData) {
const user = await requireSessionUser(); const user = await requireSessionUser();
initDatabase(); 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) { export async function createTripAction(formData: FormData) {
const user = await requireSessionUser(); const user = await requireSessionUser();
initDatabase(); initDatabase();
@@ -432,29 +669,23 @@ export async function createTripAction(formData: FormData) {
try { try {
motorcycleId = idValue(formData, "motorcycle_id"); motorcycleId = idValue(formData, "motorcycle_id");
ensureMotorcycleAccess(user, motorcycleId); ensureMotorcycleAccess(user, motorcycleId);
const tripDate = dateValue(formData, "trip_date", true); const value = tripFields(formData);
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;
transaction(() => { transaction(() => {
db.prepare( db.prepare(
`INSERT INTO trips `INSERT INTO trips
(motorcycle_id, trip_date, driver_user_id, start_km, end_km, purpose, notes) (motorcycle_id, trip_date, driver_user_id, start_km, end_km, purpose, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)`, 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( db.prepare("UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?").run(
endKm, value.endKm,
motorcycleId, 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) { export async function deleteTripAction(formData: FormData) {
const user = await requireSessionUser(); const user = await requireSessionUser();
initDatabase(); initDatabase();
+71
View File
@@ -356,6 +356,21 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); }
.form-actions { display: flex; justify-content: flex-end; } .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 & cards ---------- */
.bike-grid { .bike-grid {
display: 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 td { padding: 0.6rem 0.7rem; border-bottom: 1px solid var(--paper-line); }
.data-table tr:last-child td { border-bottom: none; } .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 { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; }
.inline-form select { width: auto; } .inline-form select { width: auto; }
@@ -608,6 +677,8 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); }
/* ---------- Responsive ---------- */ /* ---------- Responsive ---------- */
@media (max-width: 780px) { @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; } .detail-top { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.form-grid .span-2 { grid-column: auto; } .form-grid .span-2 { grid-column: auto; }
+4 -4
View File
@@ -1,12 +1,12 @@
import Link from "next/link"; import Link from "next/link";
import { db, initDatabase } from "@/lib/db"; import { db, initDatabase } from "@/lib/db";
import { formatMotorcycleName } from "@/lib/format";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type MotorcycleMaintenanceRow = { type MotorcycleMaintenanceRow = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -104,7 +104,7 @@ export default async function KioskPage({
const matches = plate && !plateError const matches = plate && !plateError
? (db ? (db
.prepare( .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.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_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 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({
<> <>
<section className="section-block"> <section className="section-block">
<div className="panel"> <div className="panel">
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p> <p className="eyebrow">Motorrad{bike.year ? ` · Baujahr ${bike.year}` : ""}</p>
<h2>{bike.nickname}</h2> <h2>{formatMotorcycleName(bike.brand, bike.model)}</h2>
<p className="muted">Kennzeichen: {bike.plate ?? "—"} · Besitzer: {bike.owner_name}</p> <p className="muted">Kennzeichen: {bike.plate ?? "—"} · Besitzer: {bike.owner_name}</p>
</div> </div>
</section> </section>
+79
View File
@@ -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 (
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Direktzugriff</p>
<h2>SQL-Konsole</h2>
</div>
</div>
<form action={formAction} className="panel stack-form">
<label>
SQLite-Befehl
<textarea
className="sql-editor mono"
name="sql"
rows={8}
required
maxLength={20_000}
defaultValue={DEFAULT_SQL}
spellCheck={false}
/>
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={pending}>
{pending ? "Wird ausgeführt…" : "SQL ausführen"}
</button>
</div>
</form>
{state.status !== "idle" && (
<div className={`flash ${state.status === "error" ? "flash-error" : "flash-success"}`}>
{state.message}
</div>
)}
{state.columns.length > 0 && (
<div className="panel table-wrap sql-result">
<table className="data-table">
<thead>
<tr>
{state.columns.map((column) => <th key={column}>{column}</th>)}
</tr>
</thead>
<tbody>
{state.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((value, columnIndex) => (
<td className="mono" key={`${rowIndex}-${columnIndex}`}>{value}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
+237 -38
View File
@@ -1,58 +1,257 @@
import { createMaintenanceAction } from "@/app/actions/motorcycles"; "use client";
import { maintenanceLabels, maintenanceTypes } from "@/lib/types";
import { useRef, useState } from "react";
import {
createMaintenanceAction,
removeMaintenanceAttachmentAction,
updateMaintenanceAction,
} from "@/app/actions/motorcycles";
import {
maintenanceLabels,
maintenanceTypes,
type MaintenanceType,
} from "@/lib/types";
function today() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
export function MaintenanceForm({ motorcycleId }: { motorcycleId: number }) { export type MaintenanceItemValue = {
id: number;
type: MaintenanceType | null;
custom_type: string | null;
cost_cents: number | null;
due_date: string | null;
due_km: number | null;
};
export type MaintenanceValue = {
id: number;
description: string | null;
event_date: string;
km: number | null;
attachment_id: number | null;
attachment_name: string | null;
items: MaintenanceItemValue[];
};
type FormItem = MaintenanceItemValue & {
formId: string;
selectedType: MaintenanceType | "__custom__";
};
function formItem(formId: string, item?: MaintenanceItemValue): FormItem {
return {
id: item?.id ?? 0,
type: item?.type ?? null,
custom_type: item?.custom_type ?? null,
cost_cents: item?.cost_cents ?? null,
due_date: item?.due_date ?? null,
due_km: item?.due_km ?? null,
formId,
selectedType: item?.type ?? (item?.custom_type ? "__custom__" : "reparatur"),
};
}
function costInput(cents: number | null) {
return cents == null ? "" : (cents / 100).toFixed(2).replace(".", ",");
}
export function MaintenanceForm({
motorcycleId,
maintenance,
}: {
motorcycleId: number;
maintenance?: MaintenanceValue;
}) {
const nextFormId = useRef((maintenance?.items.length ?? 1) + 1);
const [items, setItems] = useState<FormItem[]>(() =>
maintenance?.items.length
? maintenance.items.map((item, index) => formItem(`existing-${index}`, item))
: [formItem("new-0")],
);
function updateItem(formId: string, change: Partial<FormItem>) {
setItems((current) =>
current.map((item) => item.formId === formId ? { ...item, ...change } : item),
);
}
return ( return (
<form action={createMaintenanceAction} className="panel form-grid"> <form
action={maintenance ? updateMaintenanceAction : createMaintenanceAction}
className="panel form-grid"
>
<input type="hidden" name="motorcycle_id" value={motorcycleId} /> <input type="hidden" name="motorcycle_id" value={motorcycleId} />
<label> <input type="hidden" name="item_count" value={items.length} />
Art * {maintenance && <input type="hidden" name="id" value={maintenance.id} />}
<select name="type" defaultValue="reparatur" required>
{maintenanceTypes.map((type) => (
<option key={type} value={type}>
{maintenanceLabels[type]}
</option>
))}
</select>
</label>
<label> <label>
Datum * Datum *
<input name="event_date" type="date" required defaultValue={today()} /> <input
</label> name="event_date"
<label className="span-2"> type="date"
Titel * required
<input name="title" required maxLength={120} placeholder="z. B. Ölwechsel + Filter" /> defaultValue={maintenance?.event_date ?? today()}
</label> />
<label className="span-2">
Beschreibung
<textarea name="description" rows={3} maxLength={5000} />
</label> </label>
<label> <label>
Kilometerstand Kilometerstand
<input name="km" type="number" min="0" max="10000000" placeholder="km" /> <input
</label> name="km"
<label> type="number"
Kosten () min="0"
<input name="cost_euros" type="text" inputMode="decimal" placeholder="z. B. 149,90" /> max="10000000"
</label> placeholder="km"
<label> defaultValue={maintenance?.km ?? ""}
Nächste Fälligkeit (Datum) />
<input name="due_date" type="date" />
</label>
<label>
Nächste Fälligkeit (km)
<input name="due_km" type="number" min="0" max="10000000" />
</label> </label>
<label className="span-2"> <label className="span-2">
Beleg / Foto (PDF, JPG, PNG, WebP · max. 8 MB) Notiz zum gesamten Eintrag
<textarea
name="description"
rows={3}
maxLength={5000}
placeholder="Optionale allgemeine Hinweise zur Wartung"
defaultValue={maintenance?.description ?? ""}
/>
</label>
<div className="maintenance-items span-2">
<div className="section-heading">
<div>
<p className="eyebrow">Positionen</p>
<h3>Wartungsarten und Kosten</h3>
</div>
<button
className="button"
type="button"
onClick={() => {
const formId = `new-${nextFormId.current}`;
nextFormId.current += 1;
setItems((current) => [...current, formItem(formId)]);
}}
>
Art hinzufügen
</button>
</div>
<div className="table-wrap">
<table className="data-table maintenance-items-table">
<thead>
<tr>
<th>Art</th>
<th>Kosten ()</th>
<th>Fällig am</th>
<th>Fällig bei km</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.formId}>
<td>
<select
name={`item_type_${index}`}
value={item.selectedType}
onChange={(event) =>
updateItem(item.formId, {
selectedType: event.target.value as MaintenanceType | "__custom__",
})
}
aria-label={`Wartungsart ${index + 1}`}
required
>
{maintenanceTypes.map((type) => (
<option key={type} value={type}>{maintenanceLabels[type]}</option>
))}
<option value="__custom__">Eigene Art</option>
</select>
{item.selectedType === "__custom__" && (
<input
name={`item_custom_type_${index}`}
required
maxLength={80}
placeholder="Eigene Wartungsart"
defaultValue={item.custom_type ?? ""}
aria-label={`Eigene Wartungsart ${index + 1}`}
/>
)}
</td>
<td>
<input
name={`item_cost_euros_${index}`}
type="text"
inputMode="decimal"
placeholder="0,00"
defaultValue={costInput(item.cost_cents)}
aria-label={`Kosten ${index + 1}`}
/>
</td>
<td>
<input
name={`item_due_date_${index}`}
type="date"
defaultValue={item.due_date ?? ""}
aria-label={`Fälligkeitsdatum ${index + 1}`}
/>
</td>
<td>
<input
name={`item_due_km_${index}`}
type="number"
min="0"
max="10000000"
defaultValue={item.due_km ?? ""}
aria-label={`Fälligkeitskilometer ${index + 1}`}
/>
</td>
<td>
<button
className="link-button danger"
type="button"
disabled={items.length === 1}
onClick={() =>
setItems((current) =>
current.filter((currentItem) => currentItem.formId !== item.formId)
)
}
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<label className="span-2">
{maintenance?.attachment_id ? "Beleg / Foto ersetzen" : "Beleg / Foto"} (PDF, JPG, PNG,
WebP · max. 8 MB)
<input name="attachment" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" /> <input name="attachment" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" />
</label> </label>
{maintenance?.attachment_id && (
<div className="span-2">
<p className="muted">
Aktueller Anhang:{" "}
<a href={`/attachments/${maintenance.attachment_id}`} target="_blank" rel="noreferrer">
{maintenance.attachment_name}
</a>
</p>
<button
className="button button-danger"
type="submit"
formAction={removeMaintenanceAttachmentAction}
formNoValidate
>
Anhang entfernen
</button>
</div>
)}
<div className="form-actions span-2"> <div className="form-actions span-2">
<button className="button button-primary" type="submit">Wartungseintrag speichern</button> <button className="button button-primary" type="submit">
{maintenance ? "Änderungen speichern" : "Wartungseintrag speichern"}
</button>
</div> </div>
</form> </form>
); );
-5
View File
@@ -5,7 +5,6 @@ import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/mo
type MotorcycleValue = { type MotorcycleValue = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
@@ -34,10 +33,6 @@ export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue })
return ( return (
<form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid"> <form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid">
{motorcycle && <input type="hidden" name="id" value={motorcycle.id} />} {motorcycle && <input type="hidden" name="id" value={motorcycle.id} />}
<label>
Spitzname *
<input name="nickname" required maxLength={80} defaultValue={motorcycle?.nickname} />
</label>
<label> <label>
Hersteller * Hersteller *
<input name="brand" required maxLength={80} defaultValue={motorcycle?.brand} /> <input name="brand" required maxLength={80} defaultValue={motorcycle?.brand} />
+43 -9
View File
@@ -1,31 +1,44 @@
import { createTripAction } from "@/app/actions/motorcycles"; import { createTripAction, updateTripAction } from "@/app/actions/motorcycles";
import type { SessionUser } from "@/lib/types"; import type { SessionUser } from "@/lib/types";
function today() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
export type TripValue = {
id: number;
trip_date: string;
driver_user_id: number;
start_km: number;
end_km: number;
purpose: string | null;
notes: string | null;
};
export function TripForm({ export function TripForm({
motorcycleId, motorcycleId,
currentKm, currentKm,
drivers, drivers,
currentUserId, currentUserId,
trip,
}: { }: {
motorcycleId: number; motorcycleId: number;
currentKm: number; currentKm: number;
drivers: Pick<SessionUser, "id" | "name">[]; drivers: Pick<SessionUser, "id" | "name">[];
currentUserId: number; currentUserId: number;
trip?: TripValue;
}) { }) {
return ( return (
<form action={createTripAction} className="panel form-grid"> <form action={trip ? updateTripAction : createTripAction} className="panel form-grid">
<input type="hidden" name="motorcycle_id" value={motorcycleId} /> <input type="hidden" name="motorcycle_id" value={motorcycleId} />
{trip && <input type="hidden" name="id" value={trip.id} />}
<label> <label>
Datum * Datum *
<input name="trip_date" type="date" required defaultValue={today()} /> <input name="trip_date" type="date" required defaultValue={trip?.trip_date ?? today()} />
</label> </label>
<label> <label>
Fahrer * Fahrer *
<select name="driver_user_id" defaultValue={currentUserId} required> <select name="driver_user_id" defaultValue={trip?.driver_user_id ?? currentUserId} required>
{drivers.map((driver) => ( {drivers.map((driver) => (
<option key={driver.id} value={driver.id}> <option key={driver.id} value={driver.id}>
{driver.name} {driver.name}
@@ -35,22 +48,43 @@ export function TripForm({
</label> </label>
<label> <label>
Start-km * Start-km *
<input name="start_km" type="number" min="0" max="10000000" required defaultValue={currentKm} /> <input
name="start_km"
type="number"
min="0"
max="10000000"
required
defaultValue={trip?.start_km ?? currentKm}
/>
</label> </label>
<label> <label>
End-km * End-km *
<input name="end_km" type="number" min="0" max="10000000" required defaultValue={currentKm} /> <input
name="end_km"
type="number"
min="0"
max="10000000"
required
defaultValue={trip?.end_km ?? currentKm}
/>
</label> </label>
<label className="span-2"> <label className="span-2">
Zweck Zweck
<input name="purpose" maxLength={160} placeholder="z. B. Feierabendrunde" /> <input
name="purpose"
maxLength={160}
placeholder="z. B. Feierabendrunde"
defaultValue={trip?.purpose ?? ""}
/>
</label> </label>
<label className="span-2"> <label className="span-2">
Notizen Notizen
<textarea name="notes" rows={2} maxLength={2000} /> <textarea name="notes" rows={2} maxLength={2000} defaultValue={trip?.notes ?? ""} />
</label> </label>
<div className="form-actions span-2"> <div className="form-actions span-2">
<button className="button button-primary" type="submit">Fahrt eintragen</button> <button className="button button-primary" type="submit">
{trip ? "Änderungen speichern" : "Fahrt eintragen"}
</button>
</div> </div>
</form> </form>
); );
+64
View File
@@ -83,6 +83,28 @@ function ensureSchema() {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS maintenance_event_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
maintenance_event_id INTEGER NOT NULL REFERENCES maintenance_events(id) ON DELETE CASCADE,
type TEXT CHECK (
type IS NULL OR type IN ('reparatur', 'tuev_hu', 'inspektion', 'reifenwechsel', 'oelwechsel', 'sonstiges')
),
custom_type TEXT,
custom_type_key TEXT,
cost_cents INTEGER CHECK (cost_cents IS NULL OR cost_cents >= 0),
due_date TEXT,
due_km INTEGER CHECK (due_km IS NULL OR due_km >= 0),
position INTEGER NOT NULL CHECK (position >= 0),
CHECK (
(type IS NOT NULL AND custom_type IS NULL AND custom_type_key IS NULL) OR
(
type IS NULL AND custom_type IS NOT NULL AND custom_type_key IS NOT NULL
AND length(trim(custom_type)) > 0 AND length(custom_type_key) > 0
)
),
UNIQUE (maintenance_event_id, position)
) STRICT;
CREATE TABLE IF NOT EXISTS trips ( CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE, motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
@@ -131,6 +153,10 @@ function ensureSchema() {
ON maintenance_events(motorcycle_id, event_date DESC, id DESC); ON maintenance_events(motorcycle_id, event_date DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_maintenance_due CREATE INDEX IF NOT EXISTS idx_maintenance_due
ON maintenance_events(motorcycle_id, type, event_date DESC); ON maintenance_events(motorcycle_id, type, event_date DESC);
CREATE INDEX IF NOT EXISTS idx_maintenance_items_event
ON maintenance_event_items(maintenance_event_id, position);
CREATE INDEX IF NOT EXISTS idx_maintenance_items_due
ON maintenance_event_items(type, due_date, due_km);
CREATE INDEX IF NOT EXISTS idx_trips_motorcycle CREATE INDEX IF NOT EXISTS idx_trips_motorcycle
ON trips(motorcycle_id, trip_date DESC, id DESC); ON trips(motorcycle_id, trip_date DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
@@ -141,6 +167,44 @@ function ensureSchema() {
ON webauthn_challenges(user_id, purpose, expires_at, used_at); ON webauthn_challenges(user_id, purpose, expires_at, used_at);
`); `);
const maintenanceItemColumns = db
.prepare("PRAGMA table_info(maintenance_event_items)")
.all() as { name: string }[];
if (!maintenanceItemColumns.some((column) => column.name === "custom_type_key")) {
db.exec("ALTER TABLE maintenance_event_items ADD COLUMN custom_type_key TEXT");
}
db.exec(`
INSERT INTO maintenance_event_items
(maintenance_event_id, type, custom_type, custom_type_key, cost_cents, due_date, due_km, position)
SELECT e.id, e.type, NULL, NULL, e.cost_cents, e.due_date, e.due_km, 0
FROM maintenance_events e
WHERE NOT EXISTS (
SELECT 1
FROM maintenance_event_items item
WHERE item.maintenance_event_id = e.id
)
`);
const customMaintenanceItems = db
.prepare(
`SELECT id, custom_type
FROM maintenance_event_items
WHERE type IS NULL
AND custom_type IS NOT NULL
AND (custom_type_key IS NULL OR custom_type_key = '')`,
)
.all() as { id: number; custom_type: string }[];
const updateCustomMaintenanceKey = db.prepare(
"UPDATE maintenance_event_items SET custom_type_key = ? WHERE id = ?",
);
for (const item of customMaintenanceItems) {
updateCustomMaintenanceKey.run(item.custom_type.toLocaleLowerCase("de-DE"), item.id);
}
db.exec(
"CREATE INDEX IF NOT EXISTS idx_maintenance_items_custom_due ON maintenance_event_items(custom_type_key, due_date, due_km)",
);
const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[]; const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
if (!userColumns.some((column) => column.name === "active")) { if (!userColumns.some((column) => column.name === "active")) {
db.exec("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))"); db.exec("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))");
+4
View File
@@ -16,6 +16,10 @@ export function formatKm(value: number | null | undefined) {
return `${new Intl.NumberFormat("de-DE").format(value)} km`; return `${new Intl.NumberFormat("de-DE").format(value)} km`;
} }
export function formatMotorcycleName(brand: string, model: string) {
return `${brand} ${model}`.trim();
}
export function queryMessage( export function queryMessage(
params: Record<string, string | string[] | undefined>, params: Record<string, string | string[] | undefined>,
key: "error" | "success", key: "error" | "success",
-1
View File
@@ -19,7 +19,6 @@ export type SessionUser = {
export type MotorcycleSummary = { export type MotorcycleSummary = {
id: number; id: number;
nickname: string;
brand: string; brand: string;
model: string; model: string;
year: number | null; year: number | null;
+1 -7
View File
@@ -1,4 +1,4 @@
import { maintenanceTypes, type MaintenanceType, type UserRole } from "./types"; import type { UserRole } from "./types";
export function text( export function text(
formData: FormData, formData: FormData,
@@ -71,12 +71,6 @@ export function passwordValue(formData: FormData, key = "password") {
return value; return value;
} }
export function maintenanceType(formData: FormData): MaintenanceType {
const value = text(formData, "type", { required: true });
if (!maintenanceTypes.includes(value as MaintenanceType)) throw new Error("Ungültige Wartungsart.");
return value as MaintenanceType;
}
export function roleValue(formData: FormData): UserRole { export function roleValue(formData: FormData): UserRole {
const value = text(formData, "role", { required: true }); const value = text(formData, "role", { required: true });
if (value !== "admin" && value !== "member") throw new Error("Ungültige Rolle."); if (value !== "admin" && value !== "member") throw new Error("Ungültige Rolle.");