Add SQL management UI, maintenance, and trip edit pages for motorcycles
This commit is contained in:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<Link href="/">Übersicht</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>
|
||||
</nav>
|
||||
<div className="user-menu">
|
||||
|
||||
@@ -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({
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link>
|
||||
<Link href={`/motorcycles/${bike.id}`}>
|
||||
{formatMotorcycleName(bike.brand, bike.model)}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
Bearbeiten
|
||||
</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 { 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({
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link>
|
||||
<Link href={`/motorcycles/${bike.id}`}>
|
||||
{formatMotorcycleName(bike.brand, bike.model)}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link>
|
||||
<span>/</span>
|
||||
|
||||
@@ -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({
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link>
|
||||
<Link href={`/motorcycles/${bike.id}`}>
|
||||
{formatMotorcycleName(bike.brand, bike.model)}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
Wartungsansicht
|
||||
</div>
|
||||
|
||||
@@ -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<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
|
||||
.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({
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
{bike.nickname}
|
||||
{formatMotorcycleName(bike.brand, bike.model)}
|
||||
</div>
|
||||
|
||||
<Flash error={queryMessage(params_, "error")} success={queryMessage(params_, "success")} />
|
||||
|
||||
<div className="page-heading detail-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||
<h1>{bike.nickname}</h1>
|
||||
<p className="eyebrow">Motorrad{bike.year ? ` · Baujahr ${bike.year}` : ""}</p>
|
||||
<h1>{formatMotorcycleName(bike.brand, bike.model)}</h1>
|
||||
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
|
||||
</div>
|
||||
<div className="heading-actions">
|
||||
@@ -308,17 +337,18 @@ export default async function MotorcycleDetailPage({
|
||||
<article className="timeline-item" key={event.id}>
|
||||
<div className="timeline-main">
|
||||
<div className="timeline-head">
|
||||
<span className={`type-tag type-${event.type}`}>{maintenanceLabels[event.type]}</span>
|
||||
<strong>{event.title}</strong>
|
||||
{event.items.map((item) => (
|
||||
<span
|
||||
className={`type-tag type-${item.type ?? "sonstiges"}`}
|
||||
key={item.id}
|
||||
>
|
||||
{maintenanceItemLabel(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{event.description && <p className="muted">{event.description}</p>}
|
||||
<div className="timeline-meta">
|
||||
<span>{formatDate(event.event_date)}</span>
|
||||
<strong>{formatDate(event.event_date)}</strong>
|
||||
<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>
|
||||
{event.attachment_id && (
|
||||
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
|
||||
@@ -326,11 +356,44 @@ export default async function MotorcycleDetailPage({
|
||||
</a>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
@@ -374,10 +437,18 @@ export default async function MotorcycleDetailPage({
|
||||
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
|
||||
<td>{trip.purpose ?? "—"}</td>
|
||||
<td>
|
||||
<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 className="inline-form">
|
||||
<Link
|
||||
className="link-button"
|
||||
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>
|
||||
</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
@@ -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({
|
||||
<Link className="bike-card" href={`/motorcycles/${bike.id}`} key={bike.id}>
|
||||
<div className="bike-card-top">
|
||||
<div>
|
||||
<p className="eyebrow">{bike.brand} · {bike.model}</p>
|
||||
<h2>{bike.nickname}</h2>
|
||||
<p className="eyebrow">Motorrad</p>
|
||||
<h2>{formatMotorcycleName(bike.brand, bike.model)}</h2>
|
||||
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||
<p className="muted">
|
||||
Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name}
|
||||
@@ -180,8 +189,10 @@ export default async function Dashboard({
|
||||
dueItems.map((item) => (
|
||||
<Link href={`/motorcycles/${item.motorcycle_id}`} className="due-item" key={item.id}>
|
||||
<div>
|
||||
<strong>{item.nickname}</strong>
|
||||
<span>{maintenanceLabels[item.type]} · {item.title}</span>
|
||||
<strong>{formatMotorcycleName(item.brand, item.model)}</strong>
|
||||
<span>
|
||||
{item.type ? maintenanceLabels[item.type] : item.custom_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="due-figures">
|
||||
<span>{formatDate(item.due_date)}</span>
|
||||
|
||||
@@ -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
@@ -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<string, string> = {
|
||||
@@ -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<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) {
|
||||
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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
<section className="section-block">
|
||||
<div className="panel">
|
||||
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||
<h2>{bike.nickname}</h2>
|
||||
<p className="eyebrow">Motorrad{bike.year ? ` · Baujahr ${bike.year}` : ""}</p>
|
||||
<h2>{formatMotorcycleName(bike.brand, bike.model)}</h2>
|
||||
<p className="muted">Kennzeichen: {bike.plate ?? "—"} · Besitzer: {bike.owner_name}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,257 @@
|
||||
import { createMaintenanceAction } from "@/app/actions/motorcycles";
|
||||
import { maintenanceLabels, maintenanceTypes } from "@/lib/types";
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
createMaintenanceAction,
|
||||
removeMaintenanceAttachmentAction,
|
||||
updateMaintenanceAction,
|
||||
} from "@/app/actions/motorcycles";
|
||||
import {
|
||||
maintenanceLabels,
|
||||
maintenanceTypes,
|
||||
type MaintenanceType,
|
||||
} from "@/lib/types";
|
||||
|
||||
function today() {
|
||||
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 (
|
||||
<form action={createMaintenanceAction} className="panel form-grid">
|
||||
<form
|
||||
action={maintenance ? updateMaintenanceAction : createMaintenanceAction}
|
||||
className="panel form-grid"
|
||||
>
|
||||
<input type="hidden" name="motorcycle_id" value={motorcycleId} />
|
||||
<label>
|
||||
Art *
|
||||
<select name="type" defaultValue="reparatur" required>
|
||||
{maintenanceTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{maintenanceLabels[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="item_count" value={items.length} />
|
||||
{maintenance && <input type="hidden" name="id" value={maintenance.id} />}
|
||||
<label>
|
||||
Datum *
|
||||
<input name="event_date" type="date" required defaultValue={today()} />
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Titel *
|
||||
<input name="title" required maxLength={120} placeholder="z. B. Ölwechsel + Filter" />
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Beschreibung
|
||||
<textarea name="description" rows={3} maxLength={5000} />
|
||||
<input
|
||||
name="event_date"
|
||||
type="date"
|
||||
required
|
||||
defaultValue={maintenance?.event_date ?? today()}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Kilometerstand
|
||||
<input name="km" type="number" min="0" max="10000000" placeholder="km" />
|
||||
</label>
|
||||
<label>
|
||||
Kosten (€)
|
||||
<input name="cost_euros" type="text" inputMode="decimal" placeholder="z. B. 149,90" />
|
||||
</label>
|
||||
<label>
|
||||
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" />
|
||||
<input
|
||||
name="km"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000000"
|
||||
placeholder="km"
|
||||
defaultValue={maintenance?.km ?? ""}
|
||||
/>
|
||||
</label>
|
||||
<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" />
|
||||
</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">
|
||||
<button className="button button-primary" type="submit">Wartungseintrag speichern</button>
|
||||
<button className="button button-primary" type="submit">
|
||||
{maintenance ? "Änderungen speichern" : "Wartungseintrag speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/mo
|
||||
|
||||
type MotorcycleValue = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
@@ -34,10 +33,6 @@ export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue })
|
||||
return (
|
||||
<form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid">
|
||||
{motorcycle && <input type="hidden" name="id" value={motorcycle.id} />}
|
||||
<label>
|
||||
Spitzname *
|
||||
<input name="nickname" required maxLength={80} defaultValue={motorcycle?.nickname} />
|
||||
</label>
|
||||
<label>
|
||||
Hersteller *
|
||||
<input name="brand" required maxLength={80} defaultValue={motorcycle?.brand} />
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { createTripAction } from "@/app/actions/motorcycles";
|
||||
import { createTripAction, updateTripAction } from "@/app/actions/motorcycles";
|
||||
import type { SessionUser } from "@/lib/types";
|
||||
|
||||
function today() {
|
||||
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({
|
||||
motorcycleId,
|
||||
currentKm,
|
||||
drivers,
|
||||
currentUserId,
|
||||
trip,
|
||||
}: {
|
||||
motorcycleId: number;
|
||||
currentKm: number;
|
||||
drivers: Pick<SessionUser, "id" | "name">[];
|
||||
currentUserId: number;
|
||||
trip?: TripValue;
|
||||
}) {
|
||||
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} />
|
||||
{trip && <input type="hidden" name="id" value={trip.id} />}
|
||||
<label>
|
||||
Datum *
|
||||
<input name="trip_date" type="date" required defaultValue={today()} />
|
||||
<input name="trip_date" type="date" required defaultValue={trip?.trip_date ?? today()} />
|
||||
</label>
|
||||
<label>
|
||||
Fahrer *
|
||||
<select name="driver_user_id" defaultValue={currentUserId} required>
|
||||
<select name="driver_user_id" defaultValue={trip?.driver_user_id ?? currentUserId} required>
|
||||
{drivers.map((driver) => (
|
||||
<option key={driver.id} value={driver.id}>
|
||||
{driver.name}
|
||||
@@ -35,22 +48,43 @@ export function TripForm({
|
||||
</label>
|
||||
<label>
|
||||
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>
|
||||
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 className="span-2">
|
||||
Zweck
|
||||
<input name="purpose" maxLength={160} placeholder="z. B. Feierabendrunde" />
|
||||
<input
|
||||
name="purpose"
|
||||
maxLength={160}
|
||||
placeholder="z. B. Feierabendrunde"
|
||||
defaultValue={trip?.purpose ?? ""}
|
||||
/>
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Notizen
|
||||
<textarea name="notes" rows={2} maxLength={2000} />
|
||||
<textarea name="notes" rows={2} maxLength={2000} defaultValue={trip?.notes ?? ""} />
|
||||
</label>
|
||||
<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>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -83,6 +83,28 @@ function ensureSchema() {
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_due
|
||||
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
|
||||
ON trips(motorcycle_id, trip_date DESC, id DESC);
|
||||
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);
|
||||
`);
|
||||
|
||||
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 }[];
|
||||
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))");
|
||||
|
||||
@@ -16,6 +16,10 @@ export function formatKm(value: number | null | undefined) {
|
||||
return `${new Intl.NumberFormat("de-DE").format(value)} km`;
|
||||
}
|
||||
|
||||
export function formatMotorcycleName(brand: string, model: string) {
|
||||
return `${brand} ${model}`.trim();
|
||||
}
|
||||
|
||||
export function queryMessage(
|
||||
params: Record<string, string | string[] | undefined>,
|
||||
key: "error" | "success",
|
||||
|
||||
@@ -19,7 +19,6 @@ export type SessionUser = {
|
||||
|
||||
export type MotorcycleSummary = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { maintenanceTypes, type MaintenanceType, type UserRole } from "./types";
|
||||
import type { UserRole } from "./types";
|
||||
|
||||
export function text(
|
||||
formData: FormData,
|
||||
@@ -71,12 +71,6 @@ export function passwordValue(formData: FormData, key = "password") {
|
||||
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 {
|
||||
const value = text(formData, "role", { required: true });
|
||||
if (value !== "admin" && value !== "member") throw new Error("Ungültige Rolle.");
|
||||
|
||||
Reference in New Issue
Block a user