Init
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="23" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Moped-Tracking.iml" filepath="$PROJECT_DIR$/.idea/Moped-Tracking.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
const SESSION_COOKIE = "moped_session";
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
async function hasValidSession(request: NextRequest): Promise<boolean> {
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(token, new TextEncoder().encode(secret));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (
|
||||
pathname.startsWith("/_next") ||
|
||||
pathname.startsWith("/favicon") ||
|
||||
pathname.startsWith("/api/auth")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const isPublic = PUBLIC_PATHS.some((path) => pathname.startsWith(path));
|
||||
const validSession = await hasValidSession(request);
|
||||
|
||||
if (!validSession && !isPublic) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
if (validSession && pathname === "/login") {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!.*\\..*).*)"],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "9mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6839
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "familien-motorrad-tracker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jose": "^6.1.0",
|
||||
"next": "16.2.12",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Prompt: Familien-Motorrad-Tracker (Web-App)
|
||||
|
||||
Baue eine vollständige, produktionsreife Web-App, mit der eine Familie gemeinsam mehrere Motorräder verwaltet: Reparaturen, TÜV/HU-Termine, Inspektionen, Reifen-/Ölwechsel und ein Fahrtenbuch. Die App läuft als Docker-Projekt auf einem eigenen Server hinter einer Domain.
|
||||
|
||||
## 1. Nutzung & Rollen
|
||||
|
||||
- Mehrere Benutzer (Familie) teilen sich **einen gemeinsamen Datenbestand** (kein getrenntes Multi-Tenant-System) – jeder sieht alle Motorräder.
|
||||
- Zwei Rollen:
|
||||
- **Admin**: kann Benutzer anlegen/löschen/Rolle ändern, Motorräder löschen, alles was Mitglieder auch können.
|
||||
- **Mitglied**: kann Motorräder anlegen/bearbeiten, Wartungseinträge und Fahrten erfassen, aber keine Benutzerverwaltung und kein Löschen von Motorrädern.
|
||||
- Login per E-Mail + Passwort (kein öffentliches Self-Signup — Benutzer werden ausschließlich vom Admin angelegt).
|
||||
- Beim allerersten Start der App wird automatisch ein erster Admin-Account aus Umgebungsvariablen erzeugt (`ADMIN_EMAIL`, `ADMIN_PASSWORD`, `ADMIN_NAME`), falls noch keine Benutzer existieren.
|
||||
- Es muss immer mindestens ein Admin-Account bestehen bleiben (Löschen/Degradieren des letzten Admins verhindern).
|
||||
|
||||
## 2. Datenmodell
|
||||
|
||||
**Motorrad**
|
||||
- Name/Spitzname, Marke, Modell, Baujahr, Fahrgestellnummer (VIN), Kennzeichen, aktueller Kilometerstand, Notizen.
|
||||
|
||||
**Wartungs-Ereignis** (pro Motorrad, mehrere Einträge über die Zeit)
|
||||
- Typ: Reparatur, TÜV/HU, Inspektion, Reifenwechsel, Ölwechsel, Sonstiges
|
||||
- Titel, Beschreibung, Datum, Kilometerstand, Kosten (€)
|
||||
- Optional: nächster Fälligkeitstermin (Datum) UND/ODER nächste Fälligkeit (km-Stand) — daraus werden Erinnerungen/Status abgeleitet
|
||||
- Ersteller (welcher Benutzer hat den Eintrag angelegt)
|
||||
- Optional ein angehängtes Dokument/Foto (z. B. Rechnung, Foto vom Mängelbericht)
|
||||
|
||||
**Fahrtenbuch-Eintrag** (pro Motorrad)
|
||||
- Datum, Fahrer (Benutzer), Start-km, End-km (Distanz wird daraus berechnet), Zweck, Notizen
|
||||
|
||||
**Benutzer**
|
||||
- Name, E-Mail, Passwort (gehasht), Rolle (Admin/Mitglied)
|
||||
|
||||
Der aktuelle Kilometerstand eines Motorrads soll sich automatisch aktualisieren, wenn ein neuer Wartungseintrag oder eine Fahrt mit einem höheren km-Stand erfasst wird.
|
||||
|
||||
## 3. Kernfunktionen
|
||||
|
||||
- **Dashboard/Übersicht**: alle Motorräder auf einen Blick, mit visuellem Status für TÜV und Inspektion (grün = ok, gelb = bald fällig, rot = überfällig). Liste der nächsten anstehenden Termine über alle Motorräder hinweg.
|
||||
- **Motorrad-Detailseite**: Stammdaten, komplette Wartungshistorie (neueste zuerst), Formular zum Hinzufügen neuer Einträge inkl. Datei-Upload, Fahrtenbuch-Tabelle mit Formular zum Erfassen neuer Fahrten.
|
||||
- **Admin-Bereich**: Benutzerliste, neuen Benutzer anlegen, Rolle ändern, Benutzer löschen.
|
||||
- Responsive, auch gut auf dem Handy in der Werkstatt/unterwegs bedienbar.
|
||||
|
||||
## 4. Design-Richtung
|
||||
|
||||
- Werkstatt-/Technik-Ästhetik, keine generische SaaS-Optik. Kein cremefarbenes Standard-Design mit Terracotta-Akzent, kein reines Schwarz mit Neon-Akzent.
|
||||
- Farbpalette: dunkles warmes Anthrazit/Ink als Basis, warmes Papier-Weiß als Hintergrund für Inhalte, Stahlgrau für sekundäre Elemente, Signalgelb (Werkstatt/Warnband) als Hauptakzent, Rostrot für "überfällig", gedämpftes Grün für "ok".
|
||||
- Typografie: eine kondensierte, kräftige Headline-Schrift (z. B. Oswald) für Überschriften, eine gut lesbare Grotesk für Fließtext, eine Monospace-Schrift für Kilometerstände/Zahlen (Tacho-Optik).
|
||||
- **Signature-Element**: TÜV-Plakette als visuelles Element für Fälligkeiten — eine runde "Prüfplakette" mit Monat/Jahr, deren Farbe den Status zeigt (grün/gelb/rot). Wird im Dashboard und auf der Motorrad-Karte verwendet.
|
||||
|
||||
## 5. Technischer Rahmen
|
||||
|
||||
- **Frontend + Backend**: Next.js (App Router, TypeScript), Server Actions/Route Handlers statt separatem REST-Backend.
|
||||
- **Datenbank**: SQLite, idealerweise über Node's eingebautes `node:sqlite` oder eine reine JS-Lösung — **keine Abhängigkeit, die native Kompilierung oder externe Binär-Downloads beim Build braucht** (das erschwert Docker-Builds unnötig). Datenbankdatei liegt in einem gemounteten Volume.
|
||||
- **Auth**: Sitzungsbasiert (z. B. NextAuth mit Credentials-Provider + JWT-Session), Middleware schützt alle Seiten außer Login.
|
||||
- **Datei-Uploads**: Anhänge (Belege/Fotos) werden im Dateisystem in einem gemounteten Volume gespeichert, nicht in der Datenbank; Auslieferung über eine geschützte Route.
|
||||
- **Styling**: Tailwind CSS.
|
||||
- **Deployment**: fertiges Docker-Projekt mit `Dockerfile` (Multi-Stage-Build) und `docker-compose.yml`. Volumes für Datenbank und Uploads, damit Daten Container-Neustarts überleben. `.env.example` mit allen nötigen Variablen (Admin-Zugang, Auth-Secret, Port). Soll problemlos hinter einem Reverse Proxy (z. B. nginx/Traefik) mit eigener Domain laufen.
|
||||
- Nach dem Bauen: `npm run build` muss fehlerfrei durchlaufen; Code muss TypeScript-sauber und ohne Lint-Fehler sein.
|
||||
|
||||
## 6. Liefergegenstand
|
||||
|
||||
- Vollständiger, lauffähiger Quellcode als Projektstruktur.
|
||||
- `README.md` mit: lokaler Entwicklung, Docker-Build & -Start, Standard-Admin-Zugang, Hinweis zum Passwortwechsel nach erstem Login, Hinweis zu Backups (welche Pfade/Volumes gesichert werden müssen).
|
||||
- Kurzer Hinweis auf sinnvolle nächste Ausbaustufen (z. B. E-Mail-Erinnerungen vor Fälligkeit, Mehrsprachigkeit, PDF-Export der Wartungshistorie) — aber nicht implementieren, nur erwähnen.
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { changePasswordAction } from "@/app/actions/auth";
|
||||
import { Flash } from "@/components/flash";
|
||||
import { requireSessionUser } from "@/lib/auth";
|
||||
import { queryMessage } from "@/lib/format";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function AccountPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const user = await requireSessionUser();
|
||||
const params = await searchParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
Konto
|
||||
</div>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Mein Zugang</p>
|
||||
<h1>Konto</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Flash error={queryMessage(params, "error")} success={queryMessage(params, "success")} />
|
||||
|
||||
<section className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3 className="panel-title">Profil</h3>
|
||||
<dl className="data-list">
|
||||
<div><dt>Name</dt><dd>{user.name}</dd></div>
|
||||
<div><dt>E-Mail</dt><dd className="mono">{user.email}</dd></div>
|
||||
<div><dt>Rolle</dt><dd>{user.role === "admin" ? "Administrator" : "Mitglied"}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Sicherheit</p>
|
||||
<h2>Passwort ändern</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form action={changePasswordAction} className="panel form-grid">
|
||||
<label className="span-2">
|
||||
Aktuelles Passwort *
|
||||
<input name="current_password" type="password" required autoComplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort * (min. 10 Zeichen)
|
||||
<input name="new_password" type="password" required minLength={10} maxLength={200} autoComplete="new-password" />
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort bestätigen *
|
||||
<input name="confirm_password" type="password" required minLength={10} maxLength={200} autoComplete="new-password" />
|
||||
</label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="button button-primary" type="submit">Passwort ändern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
changeUserRoleAction,
|
||||
createUserAction,
|
||||
deleteUserAction,
|
||||
} from "@/app/actions/users";
|
||||
import { Flash } from "@/components/flash";
|
||||
import { requireAdminUser } from "@/lib/auth";
|
||||
import { db, initDatabase } from "@/lib/db";
|
||||
import { formatDate, queryMessage } from "@/lib/format";
|
||||
import type { UserRole } from "@/lib/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type UserRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default async function AdminUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const currentUser = await requireAdminUser();
|
||||
initDatabase();
|
||||
const params = await searchParams;
|
||||
|
||||
const users = db
|
||||
.prepare("SELECT id, name, email, role, created_at FROM users ORDER BY name COLLATE NOCASE")
|
||||
.all() as UserRow[];
|
||||
const adminCount = users.filter((u) => u.role === "admin").length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
Benutzer
|
||||
</div>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Administration</p>
|
||||
<h1>Benutzerverwaltung</h1>
|
||||
<p className="muted">{users.length} Konten · {adminCount} Administrator(en)</p>
|
||||
</div>
|
||||
</div>
|
||||
<Flash error={queryMessage(params, "error")} success={queryMessage(params, "success")} />
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Team</p>
|
||||
<h2>Konten</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Angelegt</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((row) => {
|
||||
const lastAdmin = row.role === "admin" && adminCount <= 1;
|
||||
const isSelf = row.id === currentUser.id;
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}{isSelf && <span className="badge">Du</span>}</td>
|
||||
<td className="mono">{row.email}</td>
|
||||
<td>
|
||||
<form action={changeUserRoleAction} className="inline-form">
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={row.role}
|
||||
disabled={lastAdmin || isSelf}
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button className="link-button" type="submit" disabled={lastAdmin || isSelf}>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{formatDate(row.created_at.slice(0, 10))}</td>
|
||||
<td>
|
||||
{!isSelf && !lastAdmin && (
|
||||
<form action={deleteUserAction} className="inline-form">
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button className="link-button danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Neu</p>
|
||||
<h2>Benutzer anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form action={createUserAction} className="panel form-grid">
|
||||
<label>
|
||||
Name *
|
||||
<input name="name" required maxLength={100} />
|
||||
</label>
|
||||
<label>
|
||||
E-Mail *
|
||||
<input name="email" type="email" required maxLength={254} />
|
||||
</label>
|
||||
<label>
|
||||
Passwort * (min. 10 Zeichen)
|
||||
<input name="password" type="password" required minLength={10} maxLength={200} />
|
||||
</label>
|
||||
<label>
|
||||
Rolle *
|
||||
<select name="role" defaultValue="member" required>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="button button-primary" type="submit">Benutzer anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { logoutAction } from "@/app/actions/auth";
|
||||
import { requireSessionUser } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireSessionUser();
|
||||
return (
|
||||
<div className="app-frame">
|
||||
<header className="topbar">
|
||||
<Link href="/" className="brand">
|
||||
<span className="brand-mark">FG</span>
|
||||
<span>
|
||||
<strong>Familiengarage</strong>
|
||||
<small>Motorrad-Tracker</small>
|
||||
</span>
|
||||
</Link>
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<Link href="/motorcycles/new">Motorrad +</Link>
|
||||
{user.role === "admin" && <Link href="/admin/users">Benutzer</Link>}
|
||||
<Link href="/account">Konto</Link>
|
||||
</nav>
|
||||
<div className="user-menu">
|
||||
<span>
|
||||
{user.name}
|
||||
<small>{user.role === "admin" ? "Administrator" : "Mitglied"}</small>
|
||||
</span>
|
||||
<form action={logoutAction}>
|
||||
<button className="link-button" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<main className="page-shell">{children}</main>
|
||||
<footer>Familiengarage · gemeinsam gepflegt</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
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";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type MotorcycleValue = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
vin: string | null;
|
||||
plate: string | null;
|
||||
current_km: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export default async function EditMotorcyclePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await requireSessionUser();
|
||||
initDatabase();
|
||||
const { id } = await params;
|
||||
const motorcycleId = Number(id);
|
||||
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
|
||||
|
||||
const bike = db
|
||||
.prepare("SELECT * FROM motorcycles WHERE id = ?")
|
||||
.get(motorcycleId) as MotorcycleValue | undefined;
|
||||
if (!bike) notFound();
|
||||
|
||||
const params_ = await searchParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link>
|
||||
<span>/</span>
|
||||
Bearbeiten
|
||||
</div>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Fuhrpark</p>
|
||||
<h1>Motorrad bearbeiten</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Flash error={queryMessage(params_, "error")} />
|
||||
<MotorcycleForm motorcycle={bike} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteMaintenanceAction, deleteMotorcycleAction, deleteTripAction } from "@/app/actions/motorcycles";
|
||||
import { Flash } from "@/components/flash";
|
||||
import { InspectionSticker } from "@/components/inspection-sticker";
|
||||
import { MaintenanceForm } from "@/components/maintenance-form";
|
||||
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 { getDueStatus } from "@/lib/status";
|
||||
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type MotorcycleRow = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
vin: string | null;
|
||||
plate: string | null;
|
||||
current_km: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
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 | null;
|
||||
attachment_id: number | null;
|
||||
attachment_name: string | null;
|
||||
};
|
||||
|
||||
type TripRow = {
|
||||
id: number;
|
||||
trip_date: string;
|
||||
driver: string | null;
|
||||
start_km: number;
|
||||
end_km: number;
|
||||
purpose: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
function latestDue(events: EventRow[], type: MaintenanceType) {
|
||||
const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null));
|
||||
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null };
|
||||
}
|
||||
|
||||
export default async function MotorcycleDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const user = await requireSessionUser();
|
||||
initDatabase();
|
||||
const { id } = await params;
|
||||
const motorcycleId = Number(id);
|
||||
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
|
||||
|
||||
const bike = db
|
||||
.prepare("SELECT * FROM motorcycles WHERE id = ?")
|
||||
.get(motorcycleId) as MotorcycleRow | undefined;
|
||||
if (!bike) notFound();
|
||||
|
||||
const params_ = await searchParams;
|
||||
|
||||
const events = 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, u.name 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
|
||||
LEFT JOIN attachments a ON a.maintenance_event_id = e.id
|
||||
WHERE e.motorcycle_id = ?
|
||||
ORDER BY e.event_date DESC, e.id DESC`,
|
||||
)
|
||||
.all(motorcycleId) as EventRow[];
|
||||
|
||||
const trips = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.trip_date, u.name AS driver, t.start_km, t.end_km, t.purpose, t.notes
|
||||
FROM trips t
|
||||
LEFT JOIN users u ON u.id = t.driver_user_id
|
||||
WHERE t.motorcycle_id = ?
|
||||
ORDER BY t.trip_date DESC, t.id DESC`,
|
||||
)
|
||||
.all(motorcycleId) as TripRow[];
|
||||
|
||||
const drivers = db.prepare("SELECT id, name FROM users ORDER BY name COLLATE NOCASE").all() as {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
|
||||
const tuev = latestDue(events, "tuev_hu");
|
||||
const inspection = latestDue(events, "inspektion");
|
||||
const tuevStatus = getDueStatus({ ...tuev, currentKm: bike.current_km });
|
||||
const inspectionStatus = getDueStatus({ ...inspection, currentKm: bike.current_km });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breadcrumbs">
|
||||
<Link href="/">Übersicht</Link>
|
||||
<span>/</span>
|
||||
{bike.nickname}
|
||||
</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="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
|
||||
</div>
|
||||
<div className="heading-actions">
|
||||
<Link className="button" href={`/motorcycles/${bike.id}/edit`}>Bearbeiten</Link>
|
||||
{user.role === "admin" && (
|
||||
<form
|
||||
action={deleteMotorcycleAction}
|
||||
className="inline-form"
|
||||
>
|
||||
<input type="hidden" name="id" value={bike.id} />
|
||||
<button className="button button-danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="detail-top">
|
||||
<div className="panel stat-panel">
|
||||
<span className="eyebrow">Aktueller Stand</span>
|
||||
<div className="odometer odometer-lg">{formatKm(bike.current_km)}</div>
|
||||
</div>
|
||||
<div className="panel status-panel">
|
||||
<div className="status-row">
|
||||
<span>TÜV / HU</span>
|
||||
<StatusPill status={tuevStatus} />
|
||||
<small>{formatDate(tuev.dueDate)} · {formatKm(tuev.dueKm)}</small>
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span>Inspektion</span>
|
||||
<StatusPill status={inspectionStatus} />
|
||||
<small>{formatDate(inspection.dueDate)} · {formatKm(inspection.dueKm)}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel sticker-panel">
|
||||
<InspectionSticker dueDate={tuev.dueDate} status={tuevStatus} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3 className="panel-title">Stammdaten</h3>
|
||||
<dl className="data-list">
|
||||
<div><dt>Hersteller</dt><dd>{bike.brand}</dd></div>
|
||||
<div><dt>Modell</dt><dd>{bike.model}</dd></div>
|
||||
<div><dt>Baujahr</dt><dd>{bike.year ?? "—"}</dd></div>
|
||||
<div><dt>Kennzeichen</dt><dd>{bike.plate ?? "—"}</dd></div>
|
||||
<div><dt>FIN / VIN</dt><dd className="mono">{bike.vin ?? "—"}</dd></div>
|
||||
</dl>
|
||||
{bike.notes && <p className="notes">{bike.notes}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Werkstattbuch</p>
|
||||
<h2>Wartungshistorie</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel timeline">
|
||||
{events.length === 0 ? (
|
||||
<p className="muted">Noch keine Wartungseinträge erfasst.</p>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<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>
|
||||
</div>
|
||||
{event.description && <p className="muted">{event.description}</p>}
|
||||
<div className="timeline-meta">
|
||||
<span>{formatDate(event.event_date)}</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>
|
||||
)}
|
||||
{event.creator && <span>von {event.creator}</span>}
|
||||
{event.attachment_id && (
|
||||
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
|
||||
📎 {event.attachment_name}
|
||||
</a>
|
||||
)}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<details className="add-block">
|
||||
<summary className="button button-primary">Wartungseintrag hinzufügen</summary>
|
||||
<MaintenanceForm motorcycleId={bike.id} />
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Fahrtenbuch</p>
|
||||
<h2>Fahrten</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel table-wrap">
|
||||
{trips.length === 0 ? (
|
||||
<p className="muted">Noch keine Fahrten erfasst.</p>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Fahrer</th>
|
||||
<th>Start</th>
|
||||
<th>Ende</th>
|
||||
<th>Distanz</th>
|
||||
<th>Zweck</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trips.map((trip) => (
|
||||
<tr key={trip.id}>
|
||||
<td>{formatDate(trip.trip_date)}</td>
|
||||
<td>{trip.driver ?? "—"}</td>
|
||||
<td className="mono">{formatKm(trip.start_km)}</td>
|
||||
<td className="mono">{formatKm(trip.end_km)}</td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<details className="add-block">
|
||||
<summary className="button button-primary">Fahrt eintragen</summary>
|
||||
<TripForm
|
||||
motorcycleId={bike.id}
|
||||
currentKm={bike.current_km}
|
||||
drivers={drivers}
|
||||
currentUserId={user.id}
|
||||
/>
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { Flash } from "@/components/flash";
|
||||
import { MotorcycleForm } from "@/components/motorcycle-form";
|
||||
import { queryMessage } from "@/lib/format";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function NewMotorcyclePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
return (
|
||||
<>
|
||||
<div className="breadcrumbs"><Link href="/">Übersicht</Link><span>/</span>Neue Maschine</div>
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">Fuhrpark</p><h1>Motorrad anlegen</h1></div>
|
||||
</div>
|
||||
<Flash error={queryMessage(params, "error")} />
|
||||
<MotorcycleForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Link from "next/link";
|
||||
import { Flash } from "@/components/flash";
|
||||
import { InspectionSticker } from "@/components/inspection-sticker";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { db, initDatabase } from "@/lib/db";
|
||||
import { formatDate, formatKm, queryMessage } from "@/lib/format";
|
||||
import { getDueStatus } from "@/lib/status";
|
||||
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type BikeRow = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
plate: string | null;
|
||||
current_km: number;
|
||||
tuev_due_date: string | null;
|
||||
tuev_due_km: number | null;
|
||||
inspection_due_date: string | null;
|
||||
inspection_due_km: number | null;
|
||||
};
|
||||
|
||||
type DueRow = {
|
||||
id: number;
|
||||
motorcycle_id: number;
|
||||
nickname: string;
|
||||
type: MaintenanceType;
|
||||
title: string;
|
||||
due_date: string | null;
|
||||
due_km: number | null;
|
||||
current_km: number;
|
||||
};
|
||||
|
||||
export default async function Dashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
initDatabase();
|
||||
const params = await searchParams;
|
||||
const motorcycles = db
|
||||
.prepare(
|
||||
`SELECT m.*,
|
||||
(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
|
||||
FROM motorcycles m ORDER BY m.nickname COLLATE NOCASE`,
|
||||
)
|
||||
.all() 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 (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
|
||||
)`,
|
||||
)
|
||||
.all() as DueRow[];
|
||||
const dueItems = dueRows
|
||||
.map((item) => ({
|
||||
...item,
|
||||
status: getDueStatus({
|
||||
dueDate: item.due_date,
|
||||
dueKm: item.due_km,
|
||||
currentKm: item.current_km,
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const rank = { overdue: 0, soon: 1, ok: 2, none: 3 };
|
||||
return rank[a.status] - rank[b.status] || (a.due_date ?? "9999").localeCompare(b.due_date ?? "9999");
|
||||
})
|
||||
.slice(0, 8);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Werkstattübersicht</p>
|
||||
<h1>Unsere Motorräder</h1>
|
||||
<p className="muted">{motorcycles.length} Maschinen im gemeinsamen Fuhrpark</p>
|
||||
</div>
|
||||
<Link className="button button-primary" href="/motorcycles/new">Motorrad anlegen</Link>
|
||||
</div>
|
||||
<Flash error={queryMessage(params, "error")} success={queryMessage(params, "success")} />
|
||||
|
||||
{motorcycles.length === 0 ? (
|
||||
<section className="empty-state panel">
|
||||
<span className="empty-icon">◇</span>
|
||||
<h2>Noch keine Maschine</h2>
|
||||
<p>Lege das erste Familienmotorrad an und beginne mit dem digitalen Werkstattbuch.</p>
|
||||
<Link className="button button-primary" href="/motorcycles/new">Erstes Motorrad anlegen</Link>
|
||||
</section>
|
||||
) : (
|
||||
<section className="bike-grid">
|
||||
{motorcycles.map((bike) => {
|
||||
const tuevStatus = getDueStatus({
|
||||
dueDate: bike.tuev_due_date,
|
||||
dueKm: bike.tuev_due_km,
|
||||
currentKm: bike.current_km,
|
||||
});
|
||||
const inspectionStatus = getDueStatus({
|
||||
dueDate: bike.inspection_due_date,
|
||||
dueKm: bike.inspection_due_km,
|
||||
currentKm: bike.current_km,
|
||||
});
|
||||
return (
|
||||
<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="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||
</div>
|
||||
<InspectionSticker dueDate={bike.tuev_due_date} status={tuevStatus} />
|
||||
</div>
|
||||
<div className="odometer">{formatKm(bike.current_km)}</div>
|
||||
<div className="status-row">
|
||||
<span>TÜV / HU</span>
|
||||
<StatusPill status={tuevStatus} />
|
||||
<small>{formatDate(bike.tuev_due_date)} · {formatKm(bike.tuev_due_km)}</small>
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span>Inspektion</span>
|
||||
<StatusPill status={inspectionStatus} />
|
||||
<small>{formatDate(bike.inspection_due_date)} · {formatKm(bike.inspection_due_km)}</small>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Termine & Intervalle</p>
|
||||
<h2>Anstehende Fälligkeiten</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel due-list">
|
||||
{dueItems.length === 0 ? (
|
||||
<p className="muted">Noch keine Fälligkeiten hinterlegt.</p>
|
||||
) : (
|
||||
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>
|
||||
</div>
|
||||
<div className="due-figures">
|
||||
<span>{formatDate(item.due_date)}</span>
|
||||
<span>{formatKm(item.due_km)}</span>
|
||||
</div>
|
||||
<StatusPill status={item.status} />
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { compareSync, hashSync } from "bcryptjs";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
authenticateUser,
|
||||
clearSessionCookie,
|
||||
requireSessionUser,
|
||||
setSessionCookie,
|
||||
} from "@/lib/auth";
|
||||
import { db, initDatabase } from "@/lib/db";
|
||||
import { emailValue, passwordValue } from "@/lib/validation";
|
||||
|
||||
function messageUrl(path: string, kind: "error" | "success", message: string) {
|
||||
return `${path}?${kind}=${encodeURIComponent(message)}`;
|
||||
}
|
||||
|
||||
export async function loginAction(formData: FormData) {
|
||||
let email: string;
|
||||
let password: string;
|
||||
try {
|
||||
email = emailValue(formData);
|
||||
password = String(formData.get("password") ?? "");
|
||||
} catch (error) {
|
||||
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
||||
}
|
||||
|
||||
const user = authenticateUser(email, password);
|
||||
if (!user) redirect(messageUrl("/login", "error", "E-Mail oder Passwort ist falsch."));
|
||||
await setSessionCookie(user);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
await clearSessionCookie();
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
export async function changePasswordAction(formData: FormData) {
|
||||
const user = await requireSessionUser();
|
||||
initDatabase();
|
||||
|
||||
let currentPassword: string;
|
||||
let newPassword: string;
|
||||
try {
|
||||
currentPassword = String(formData.get("current_password") ?? "");
|
||||
newPassword = passwordValue(formData, "new_password");
|
||||
if (newPassword !== String(formData.get("confirm_password") ?? "")) {
|
||||
throw new Error("Die neuen Passwörter stimmen nicht überein.");
|
||||
}
|
||||
if (currentPassword === newPassword) {
|
||||
throw new Error("Das neue Passwort muss sich vom bisherigen unterscheiden.");
|
||||
}
|
||||
} catch (error) {
|
||||
redirect(messageUrl("/account", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
||||
}
|
||||
|
||||
const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(user.id) as
|
||||
| { password_hash: string }
|
||||
| undefined;
|
||||
if (!row || !compareSync(currentPassword, row.password_hash)) {
|
||||
redirect(messageUrl("/account", "error", "Das aktuelle Passwort ist falsch."));
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashSync(newPassword, 12), user.id);
|
||||
redirect(messageUrl("/account", "success", "Passwort wurde geändert."));
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, join } from "node:path";
|
||||
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { redirect, unstable_rethrow } from "next/navigation";
|
||||
import { requireAdminUser, requireSessionUser } from "@/lib/auth";
|
||||
import { db, initDatabase, transaction } from "@/lib/db";
|
||||
import { getUploadDir } from "@/lib/env";
|
||||
import {
|
||||
dateValue,
|
||||
idValue,
|
||||
integer,
|
||||
maintenanceType,
|
||||
text,
|
||||
} from "@/lib/validation";
|
||||
|
||||
const MAX_FILE_SIZE = 8 * 1024 * 1024;
|
||||
const MIME_EXTENSIONS: Record<string, string> = {
|
||||
"application/pdf": ".pdf",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
};
|
||||
|
||||
function go(path: string, kind: "error" | "success", message: string): never {
|
||||
redirect(`${path}?${kind}=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
|
||||
}
|
||||
|
||||
function motorcycleExists(id: number) {
|
||||
return Boolean(db.prepare("SELECT 1 FROM motorcycles WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
function motorcycleFields(formData: FormData) {
|
||||
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 }),
|
||||
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,
|
||||
currentKm: integer(formData, "current_km", { required: true, min: 0, max: 10_000_000 }) ?? 0,
|
||||
notes: text(formData, "notes", { max: 5000 }) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMotorcycleAction(formData: FormData) {
|
||||
const user = await requireSessionUser();
|
||||
initDatabase();
|
||||
try {
|
||||
const value = motorcycleFields(formData);
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO motorcycles
|
||||
(nickname, brand, model, year, vin, plate, current_km, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
value.nickname,
|
||||
value.brand,
|
||||
value.model,
|
||||
value.year,
|
||||
value.vin,
|
||||
value.plate,
|
||||
value.currentKm,
|
||||
value.notes,
|
||||
user.id,
|
||||
);
|
||||
redirect(`/motorcycles/${Number(result.lastInsertRowid)}?success=${encodeURIComponent("Motorrad angelegt.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go("/motorcycles/new", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMotorcycleAction(formData: FormData) {
|
||||
await requireSessionUser();
|
||||
initDatabase();
|
||||
let id = 0;
|
||||
try {
|
||||
id = idValue(formData);
|
||||
const value = motorcycleFields(formData);
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE motorcycles
|
||||
SET nickname = ?, brand = ?, model = ?, year = ?, vin = ?, plate = ?,
|
||||
current_km = ?, notes = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(
|
||||
value.nickname,
|
||||
value.brand,
|
||||
value.model,
|
||||
value.year,
|
||||
value.vin,
|
||||
value.plate,
|
||||
value.currentKm,
|
||||
value.notes,
|
||||
id,
|
||||
);
|
||||
if (result.changes === 0) throw new Error("Motorrad wurde nicht gefunden.");
|
||||
redirect(`/motorcycles/${id}?success=${encodeURIComponent("Motorrad gespeichert.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go(id ? `/motorcycles/${id}/edit` : "/", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMotorcycleAction(formData: FormData) {
|
||||
await requireAdminUser();
|
||||
initDatabase();
|
||||
try {
|
||||
const id = idValue(formData);
|
||||
const files = db
|
||||
.prepare(
|
||||
`SELECT a.stored_name
|
||||
FROM attachments a
|
||||
JOIN maintenance_events m ON m.id = a.maintenance_event_id
|
||||
WHERE m.motorcycle_id = ?`,
|
||||
)
|
||||
.all(id) as { stored_name: string }[];
|
||||
const result = db.prepare("DELETE FROM motorcycles WHERE id = ?").run(id);
|
||||
if (result.changes === 0) throw new Error("Motorrad wurde nicht gefunden.");
|
||||
for (const file of files) {
|
||||
const path = join(getUploadDir(), basename(file.stored_name));
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
}
|
||||
redirect(`/?success=${encodeURIComponent("Motorrad gelöscht.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go("/", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function validateFile(file: File | null) {
|
||||
if (!file || file.size === 0) return null;
|
||||
if (file.size > MAX_FILE_SIZE) throw new Error("Der Anhang darf höchstens 8 MB groß sein.");
|
||||
const extension = MIME_EXTENSIONS[file.type];
|
||||
if (!extension) throw new Error("Erlaubt sind PDF, JPG, PNG und WebP.");
|
||||
return { file, extension };
|
||||
}
|
||||
|
||||
function hasExpectedSignature(bytes: Uint8Array, mime: string) {
|
||||
if (mime === "application/pdf") return bytes.slice(0, 5).toString() === "37,80,68,70,45";
|
||||
if (mime === "image/jpeg") return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
if (mime === "image/png")
|
||||
return bytes.slice(0, 8).toString() === "137,80,78,71,13,10,26,10";
|
||||
if (mime === "image/webp")
|
||||
return new TextDecoder().decode(bytes.slice(0, 4)) === "RIFF" &&
|
||||
new TextDecoder().decode(bytes.slice(8, 12)) === "WEBP";
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createMaintenanceAction(formData: FormData) {
|
||||
const user = await requireSessionUser();
|
||||
initDatabase();
|
||||
let motorcycleId = 0;
|
||||
let storedPath: string | null = null;
|
||||
|
||||
try {
|
||||
motorcycleId = idValue(formData, "motorcycle_id");
|
||||
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO maintenance_events
|
||||
(motorcycle_id, type, title, description, event_date, km, cost_cents, due_date, due_km, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
motorcycleId,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
eventDate,
|
||||
km,
|
||||
costCents,
|
||||
dueDate,
|
||||
dueKm,
|
||||
user.id,
|
||||
);
|
||||
const eventId = Number(result.lastInsertRowid);
|
||||
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 (km != null) {
|
||||
db.prepare(
|
||||
"UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?",
|
||||
).run(km, motorcycleId);
|
||||
}
|
||||
});
|
||||
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Wartungseintrag gespeichert.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
if (storedPath && existsSync(storedPath)) unlinkSync(storedPath);
|
||||
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMaintenanceAction(formData: FormData) {
|
||||
await requireSessionUser();
|
||||
initDatabase();
|
||||
let motorcycleId = 0;
|
||||
try {
|
||||
const id = 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(id) as { motorcycle_id: number; stored_name: string | null } | undefined;
|
||||
if (!row) throw new Error("Wartungseintrag wurde nicht gefunden.");
|
||||
motorcycleId = row.motorcycle_id;
|
||||
db.prepare("DELETE FROM maintenance_events WHERE id = ?").run(id);
|
||||
if (row.stored_name) {
|
||||
const path = join(getUploadDir(), basename(row.stored_name));
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
}
|
||||
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Wartungseintrag gelöscht.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTripAction(formData: FormData) {
|
||||
await requireSessionUser();
|
||||
initDatabase();
|
||||
let motorcycleId = 0;
|
||||
try {
|
||||
motorcycleId = idValue(formData, "motorcycle_id");
|
||||
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
|
||||
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 = ?").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(() => {
|
||||
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);
|
||||
db.prepare("UPDATE motorcycles SET current_km = MAX(current_km, ?) WHERE id = ?").run(
|
||||
endKm,
|
||||
motorcycleId,
|
||||
);
|
||||
});
|
||||
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gespeichert.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTripAction(formData: FormData) {
|
||||
await requireSessionUser();
|
||||
initDatabase();
|
||||
let motorcycleId = 0;
|
||||
try {
|
||||
const id = idValue(formData);
|
||||
const row = db.prepare("SELECT motorcycle_id FROM trips WHERE id = ?").get(id) as
|
||||
| { motorcycle_id: number }
|
||||
| undefined;
|
||||
if (!row) throw new Error("Fahrt wurde nicht gefunden.");
|
||||
motorcycleId = row.motorcycle_id;
|
||||
db.prepare("DELETE FROM trips WHERE id = ?").run(id);
|
||||
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gelöscht.")}`);
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use server";
|
||||
|
||||
import { hashSync } from "bcryptjs";
|
||||
import { redirect, unstable_rethrow } from "next/navigation";
|
||||
import { requireAdminUser } from "@/lib/auth";
|
||||
import { db, initDatabase, transaction } from "@/lib/db";
|
||||
import { emailValue, idValue, passwordValue, roleValue, text } from "@/lib/validation";
|
||||
import type { UserRole } from "@/lib/types";
|
||||
|
||||
function go(kind: "error" | "success", message: string): never {
|
||||
redirect(`/admin/users?${kind}=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
|
||||
return "Diese E-Mail-Adresse ist bereits vergeben.";
|
||||
}
|
||||
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
|
||||
}
|
||||
|
||||
export async function createUserAction(formData: FormData) {
|
||||
await requireAdminUser();
|
||||
initDatabase();
|
||||
try {
|
||||
const name = text(formData, "name", { required: true, max: 100 });
|
||||
const email = emailValue(formData);
|
||||
const password = passwordValue(formData);
|
||||
const role = roleValue(formData);
|
||||
db.prepare(
|
||||
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)",
|
||||
).run(name, email, hashSync(password, 12), role);
|
||||
go("success", "Benutzer wurde angelegt.");
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go("error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeUserRoleAction(formData: FormData) {
|
||||
const currentUser = await requireAdminUser();
|
||||
initDatabase();
|
||||
try {
|
||||
const id = idValue(formData);
|
||||
const role = roleValue(formData);
|
||||
transaction(() => {
|
||||
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
|
||||
| { role: UserRole }
|
||||
| undefined;
|
||||
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
||||
if (target.role === "admin" && role === "member") {
|
||||
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
|
||||
count: number;
|
||||
};
|
||||
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht herabgestuft werden.");
|
||||
if (id === currentUser.id) {
|
||||
throw new Error("Die eigene Administratorrolle kann nicht herabgestuft werden.");
|
||||
}
|
||||
}
|
||||
db.prepare("UPDATE users SET role = ? WHERE id = ?").run(role, id);
|
||||
});
|
||||
go("success", "Rolle wurde geändert.");
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go("error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUserAction(formData: FormData) {
|
||||
const currentUser = await requireAdminUser();
|
||||
initDatabase();
|
||||
try {
|
||||
const id = idValue(formData);
|
||||
if (id === currentUser.id) throw new Error("Das aktuell angemeldete Konto kann nicht gelöscht werden.");
|
||||
transaction(() => {
|
||||
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
|
||||
| { role: UserRole }
|
||||
| undefined;
|
||||
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
||||
if (target.role === "admin") {
|
||||
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
|
||||
count: number;
|
||||
};
|
||||
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht gelöscht werden.");
|
||||
}
|
||||
try {
|
||||
db.prepare("DELETE FROM users WHERE id = ?").run(id);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("FOREIGN KEY")) {
|
||||
throw new Error("Benutzer mit vorhandenen Einträgen kann nicht gelöscht werden.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
go("success", "Benutzer wurde gelöscht.");
|
||||
} catch (error) {
|
||||
unstable_rethrow(error);
|
||||
go("error", errorMessage(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { basename, join } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { db, initDatabase } from "@/lib/db";
|
||||
import { getUploadDir } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type AttachmentRow = {
|
||||
stored_name: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
};
|
||||
|
||||
export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments/[id]">) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return new Response("Nicht angemeldet.", { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await ctx.params;
|
||||
const attachmentId = Number(id);
|
||||
if (!Number.isSafeInteger(attachmentId) || attachmentId < 1) {
|
||||
return new Response("Ungültige Anfrage.", { status: 400 });
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
const row = db
|
||||
.prepare("SELECT stored_name, original_name, mime_type FROM attachments WHERE id = ?")
|
||||
.get(attachmentId) as AttachmentRow | undefined;
|
||||
if (!row) {
|
||||
return new Response("Anhang wurde nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const filePath = join(getUploadDir(), basename(row.stored_name));
|
||||
if (!existsSync(filePath)) {
|
||||
return new Response("Datei wurde nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const data = readFileSync(filePath);
|
||||
const safeName = row.original_name.replace(/[^\w.\-() ]+/g, "_");
|
||||
|
||||
return new Response(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": row.mime_type,
|
||||
"Content-Length": String(data.length),
|
||||
"Content-Disposition": `inline; filename="${safeName}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,617 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Oswald:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--ink: #1d1a16;
|
||||
--ink-2: #2b2721;
|
||||
--ink-3: #3a352d;
|
||||
--paper: #f3ede2;
|
||||
--paper-2: #fbf7ef;
|
||||
--paper-line: #e2d9c9;
|
||||
--steel: #7d786f;
|
||||
--steel-2: #b6afa2;
|
||||
--signal: #f4c111;
|
||||
--signal-dark: #d9a900;
|
||||
--rust: #b7410e;
|
||||
--rust-soft: #f3d9cd;
|
||||
--green: #4f7a4d;
|
||||
--green-soft: #d8e5d3;
|
||||
--amber: #c98a12;
|
||||
--amber-soft: #f6e4bd;
|
||||
|
||||
--font-head: "Oswald", "Arial Narrow", sans-serif;
|
||||
--font-body: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
|
||||
--radius: 10px;
|
||||
--shadow: 0 12px 30px -18px rgba(29, 26, 22, 0.6);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 100% -10%, rgba(244, 193, 17, 0.08), transparent 60%),
|
||||
var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.15rem; }
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--steel);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.muted { color: var(--steel); margin: 0.15rem 0; }
|
||||
|
||||
/* ---------- App frame ---------- */
|
||||
.app-frame {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.85rem clamp(1rem, 4vw, 2.5rem);
|
||||
background: var(--ink);
|
||||
color: var(--paper-2);
|
||||
border-bottom: 3px solid var(--signal);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--signal);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.brand small { color: var(--steel-2); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
|
||||
.topbar nav {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.topbar nav a {
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--paper-2);
|
||||
font-size: 0.92rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.topbar nav a:hover { background: rgba(255, 255, 255, 0.09); }
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.user-menu > span {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-menu small { display: block; color: var(--steel-2); font-weight: 400; font-size: 0.72rem; }
|
||||
|
||||
.page-shell {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1.2rem, 3vw, 2.5rem) clamp(1rem, 4vw, 2.5rem) 3rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1.4rem;
|
||||
color: var(--steel);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-top: 1px solid var(--paper-line);
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ink);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-head);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.08s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.button:hover { background: var(--ink); color: var(--paper-2); }
|
||||
.button:active { transform: translateY(1px); }
|
||||
|
||||
.button-primary {
|
||||
background: var(--signal);
|
||||
border-color: var(--signal-dark);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 6px 16px -8px rgba(217, 169, 0, 0.9);
|
||||
}
|
||||
|
||||
.button-primary:hover { background: var(--signal-dark); color: var(--ink); }
|
||||
|
||||
.button-danger { border-color: var(--rust); color: var(--rust); }
|
||||
.button-danger:hover { background: var(--rust); color: var(--paper-2); }
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--steel);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.link-button:hover { color: var(--ink); }
|
||||
.link-button.danger { color: var(--rust); }
|
||||
.link-button:disabled { color: var(--steel-2); cursor: not-allowed; text-decoration: none; }
|
||||
|
||||
/* ---------- Panels & layout blocks ---------- */
|
||||
.panel {
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--paper-line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.3rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-title { margin-bottom: 0.9rem; }
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.heading-actions { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--steel);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumbs a { color: var(--steel); text-decoration: none; }
|
||||
.breadcrumbs a:hover { color: var(--ink); }
|
||||
.breadcrumbs span { color: var(--steel-2); }
|
||||
|
||||
.section-block { margin-top: 2.2rem; }
|
||||
|
||||
.section-heading { margin-bottom: 0.9rem; }
|
||||
|
||||
/* ---------- Flash ---------- */
|
||||
.flash {
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.flash-error { background: var(--rust-soft); border-color: var(--rust); color: #6f2708; }
|
||||
.flash-success { background: var(--green-soft); border-color: var(--green); color: #2c4a2b; }
|
||||
|
||||
/* ---------- Login ---------- */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(900px 400px at 50% -10%, rgba(244, 193, 17, 0.12), transparent 60%),
|
||||
var(--ink);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--paper-2);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.3rem;
|
||||
border-top: 4px solid var(--signal);
|
||||
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.login-brand strong { color: var(--ink); }
|
||||
.login-brand small { color: var(--steel); }
|
||||
|
||||
.stack-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--steel-2);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--signal-dark);
|
||||
box-shadow: 0 0 0 3px rgba(244, 193, 17, 0.25);
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
|
||||
input[type="file"] { padding: 0.4rem; background: var(--paper); }
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-grid .span-2 { grid-column: 1 / -1; }
|
||||
|
||||
.form-actions { display: flex; justify-content: flex-end; }
|
||||
|
||||
/* ---------- Bike grid & cards ---------- */
|
||||
.bike-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.bike-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
padding: 1.2rem;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--paper-line);
|
||||
border-left: 5px solid var(--ink);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.1s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.bike-card:hover { transform: translateY(-3px); border-left-color: var(--signal); }
|
||||
|
||||
.bike-card-top { display: flex; justify-content: space-between; gap: 0.8rem; }
|
||||
.bike-card-top h2 { font-size: 1.3rem; }
|
||||
|
||||
.odometer {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--ink);
|
||||
color: var(--signal);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.odometer-lg { font-size: 2rem; margin-top: 0.5rem; }
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-row small { color: var(--steel); font-weight: 400; margin-left: auto; }
|
||||
.status-row + .status-row { margin-top: 0.6rem; }
|
||||
|
||||
/* ---------- Status pill ---------- */
|
||||
.status-pill {
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-ok { background: var(--green-soft); color: #2c4a2b; border-color: var(--green); }
|
||||
.status-soon { background: var(--amber-soft); color: #7a5406; border-color: var(--amber); }
|
||||
.status-overdue { background: var(--rust-soft); color: #6f2708; border-color: var(--rust); }
|
||||
.status-none { background: var(--paper); color: var(--steel); border-color: var(--steel-2); }
|
||||
|
||||
/* ---------- Inspection sticker ---------- */
|
||||
.sticker {
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
border: 3px solid rgba(255, 255, 255, 0.75);
|
||||
box-shadow: 0 0 0 2px var(--green), var(--shadow);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sticker span { font-size: 0.85rem; font-weight: 600; }
|
||||
.sticker strong { font-size: 1.5rem; font-weight: 700; }
|
||||
.sticker small { font-size: 0.6rem; letter-spacing: 0.15em; }
|
||||
.sticker-empty { background: var(--steel); box-shadow: 0 0 0 2px var(--steel), var(--shadow); }
|
||||
.sticker-ok { background: var(--green); box-shadow: 0 0 0 2px var(--green), var(--shadow); }
|
||||
.sticker-soon { background: var(--amber); color: var(--ink); box-shadow: 0 0 0 2px var(--amber), var(--shadow); }
|
||||
.sticker-overdue { background: var(--rust); box-shadow: 0 0 0 2px var(--rust), var(--shadow); }
|
||||
.sticker-none { background: var(--steel); box-shadow: 0 0 0 2px var(--steel), var(--shadow); }
|
||||
|
||||
/* ---------- Due list ---------- */
|
||||
.due-list { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.5rem; }
|
||||
|
||||
.due-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.due-item:hover { background: var(--paper); }
|
||||
.due-item > div:first-child { display: flex; flex-direction: column; }
|
||||
.due-item strong { font-size: 0.98rem; }
|
||||
.due-item span { color: var(--steel); font-size: 0.85rem; }
|
||||
.due-figures { margin-left: auto; text-align: right; font-family: var(--font-mono); font-size: 0.82rem; }
|
||||
.due-figures span { display: block; color: var(--ink-2); }
|
||||
|
||||
/* ---------- Empty state ---------- */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 2.4rem; color: var(--signal-dark); }
|
||||
|
||||
/* ---------- Detail page ---------- */
|
||||
.detail-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.6fr auto;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.stat-panel { display: flex; flex-direction: column; }
|
||||
.status-panel { display: flex; flex-direction: column; justify-content: center; }
|
||||
.sticker-panel { display: grid; place-items: center; }
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.8rem; margin: 0; }
|
||||
.data-list dt { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--steel); }
|
||||
.data-list dd { margin: 0.1rem 0 0; font-weight: 600; }
|
||||
|
||||
.notes {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed var(--paper-line);
|
||||
white-space: pre-wrap;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* ---------- Timeline ---------- */
|
||||
.timeline { display: flex; flex-direction: column; gap: 0; padding: 0; }
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.2rem;
|
||||
border-bottom: 1px solid var(--paper-line);
|
||||
}
|
||||
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-main { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.timeline-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.timeline-head strong { font-size: 1.02rem; }
|
||||
|
||||
.timeline-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
color: var(--steel);
|
||||
}
|
||||
|
||||
.timeline-meta a { color: var(--rust); font-weight: 600; text-decoration: none; }
|
||||
.timeline-meta a:hover { text-decoration: underline; }
|
||||
|
||||
.type-tag {
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 5px;
|
||||
background: var(--ink);
|
||||
color: var(--paper-2);
|
||||
}
|
||||
|
||||
.type-tuev_hu { background: var(--rust); }
|
||||
.type-inspektion { background: var(--amber); color: var(--ink); }
|
||||
.type-oelwechsel { background: var(--ink-3); }
|
||||
.type-reifenwechsel { background: var(--steel); }
|
||||
.type-reparatur { background: var(--ink); }
|
||||
.type-sonstiges { background: var(--steel-2); color: var(--ink); }
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
.table-wrap { overflow-x: auto; padding: 0.4rem; }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--steel);
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-bottom: 2px 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; }
|
||||
|
||||
.inline-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; }
|
||||
.inline-form select { width: auto; }
|
||||
|
||||
.badge {
|
||||
margin-left: 0.5rem;
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--signal);
|
||||
color: var(--ink);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Add block (details/summary) ---------- */
|
||||
.add-block { margin-top: 1rem; }
|
||||
.add-block > summary { display: inline-flex; list-style: none; margin-bottom: 0; }
|
||||
.add-block > summary::-webkit-details-marker { display: none; }
|
||||
.add-block[open] > summary { margin-bottom: 1rem; }
|
||||
.add-block[open] > summary::after { content: " ✕"; }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 780px) {
|
||||
.detail-top { grid-template-columns: 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid .span-2 { grid-column: auto; }
|
||||
.topbar { gap: 0.8rem; }
|
||||
.topbar nav { order: 3; width: 100%; }
|
||||
.user-menu { margin-left: auto; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Familiengarage",
|
||||
template: "%s · Familiengarage",
|
||||
},
|
||||
description: "Gemeinsamer Motorrad-, Wartungs- und Fahrten-Tracker für die Familie.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { loginAction } from "@/app/actions/auth";
|
||||
import { Flash } from "@/components/flash";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { initDatabase } from "@/lib/db";
|
||||
import { queryMessage } from "@/lib/format";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
initDatabase();
|
||||
if (await getSessionUser()) redirect("/");
|
||||
const params = await searchParams;
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-card">
|
||||
<div className="brand login-brand">
|
||||
<span className="brand-mark">FG</span>
|
||||
<span>
|
||||
<strong>Familiengarage</strong>
|
||||
<small>Werkstattbuch & Fahrten</small>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">Willkommen zurück</p>
|
||||
<h1>Anmelden</h1>
|
||||
<p className="muted">Zugang nur für eingerichtete Familienmitglieder.</p>
|
||||
</div>
|
||||
<Flash error={queryMessage(params, "error")} />
|
||||
<form action={loginAction} className="stack-form">
|
||||
<label>
|
||||
E-Mail
|
||||
<input name="email" type="email" autoComplete="email" required autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input name="password" type="password" autoComplete="current-password" required />
|
||||
</label>
|
||||
<button className="button button-primary" type="submit">Anmelden</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function Flash({
|
||||
error,
|
||||
success,
|
||||
}: {
|
||||
error?: string | null;
|
||||
success?: string | null;
|
||||
}) {
|
||||
if (!error && !success) return null;
|
||||
return (
|
||||
<div className={`flash ${error ? "flash-error" : "flash-success"}`} role="status">
|
||||
{error ?? success}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { DueStatus } from "@/lib/types";
|
||||
|
||||
export function InspectionSticker({
|
||||
dueDate,
|
||||
status,
|
||||
}: {
|
||||
dueDate: string | null;
|
||||
status?: DueStatus;
|
||||
}) {
|
||||
if (!dueDate) {
|
||||
return (
|
||||
<div className="sticker sticker-empty" aria-label="Kein TÜV-Termin">
|
||||
<span>HU</span>
|
||||
<strong>—</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const date = new Date(`${dueDate}T00:00:00`);
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const tone = status ? `sticker-${status}` : "";
|
||||
return (
|
||||
<div className={`sticker ${tone}`} aria-label={`TÜV fällig ${month}/${date.getFullYear()}`}>
|
||||
<span>{month}</span>
|
||||
<strong>{year}</strong>
|
||||
<small>HU</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createMaintenanceAction } from "@/app/actions/motorcycles";
|
||||
import { maintenanceLabels, maintenanceTypes } from "@/lib/types";
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function MaintenanceForm({ motorcycleId }: { motorcycleId: number }) {
|
||||
return (
|
||||
<form action={createMaintenanceAction} className="panel form-grid" encType="multipart/form-data">
|
||||
<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>
|
||||
<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} />
|
||||
</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" />
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Beleg / Foto (PDF, JPG, PNG, WebP · max. 8 MB)
|
||||
<input name="attachment" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" />
|
||||
</label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="button button-primary" type="submit">Wartungseintrag speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/motorcycles";
|
||||
|
||||
type MotorcycleValue = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
vin: string | null;
|
||||
plate: string | null;
|
||||
current_km: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
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} />
|
||||
</label>
|
||||
<label>
|
||||
Modell *
|
||||
<input name="model" required maxLength={100} defaultValue={motorcycle?.model} />
|
||||
</label>
|
||||
<label>
|
||||
Baujahr
|
||||
<input
|
||||
name="year"
|
||||
type="number"
|
||||
min="1900"
|
||||
max={new Date().getFullYear() + 1}
|
||||
defaultValue={motorcycle?.year ?? ""}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Kennzeichen
|
||||
<input name="plate" maxLength={30} defaultValue={motorcycle?.plate ?? ""} />
|
||||
</label>
|
||||
<label>
|
||||
FIN / VIN
|
||||
<input name="vin" maxLength={50} defaultValue={motorcycle?.vin ?? ""} />
|
||||
</label>
|
||||
<label>
|
||||
Kilometerstand *
|
||||
<input
|
||||
name="current_km"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000000"
|
||||
required
|
||||
defaultValue={motorcycle?.current_km ?? 0}
|
||||
/>
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Notizen
|
||||
<textarea name="notes" rows={5} maxLength={5000} defaultValue={motorcycle?.notes ?? ""} />
|
||||
</label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="button button-primary" type="submit">
|
||||
{motorcycle ? "Änderungen speichern" : "Motorrad anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { statusClasses, statusLabel } from "@/lib/status";
|
||||
import type { DueStatus } from "@/lib/types";
|
||||
|
||||
export function StatusPill({ status }: { status: DueStatus }) {
|
||||
return <span className={`status-pill ${statusClasses(status)}`}>{statusLabel(status)}</span>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createTripAction } from "@/app/actions/motorcycles";
|
||||
import type { SessionUser } from "@/lib/types";
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function TripForm({
|
||||
motorcycleId,
|
||||
currentKm,
|
||||
drivers,
|
||||
currentUserId,
|
||||
}: {
|
||||
motorcycleId: number;
|
||||
currentKm: number;
|
||||
drivers: Pick<SessionUser, "id" | "name">[];
|
||||
currentUserId: number;
|
||||
}) {
|
||||
return (
|
||||
<form action={createTripAction} className="panel form-grid">
|
||||
<input type="hidden" name="motorcycle_id" value={motorcycleId} />
|
||||
<label>
|
||||
Datum *
|
||||
<input name="trip_date" type="date" required defaultValue={today()} />
|
||||
</label>
|
||||
<label>
|
||||
Fahrer *
|
||||
<select name="driver_user_id" defaultValue={currentUserId} required>
|
||||
{drivers.map((driver) => (
|
||||
<option key={driver.id} value={driver.id}>
|
||||
{driver.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Start-km *
|
||||
<input name="start_km" type="number" min="0" max="10000000" required defaultValue={currentKm} />
|
||||
</label>
|
||||
<label>
|
||||
End-km *
|
||||
<input name="end_km" type="number" min="0" max="10000000" required defaultValue={currentKm} />
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Zweck
|
||||
<input name="purpose" maxLength={160} placeholder="z. B. Feierabendrunde" />
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Notizen
|
||||
<textarea name="notes" rows={2} maxLength={2000} />
|
||||
</label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="button button-primary" type="submit">Fahrt eintragen</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { compareSync } from "bcryptjs";
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db, initDatabase } from "./db";
|
||||
import { getAuthSecret } from "./env";
|
||||
import type { SessionUser, UserRole } from "./types";
|
||||
|
||||
type SessionPayload = {
|
||||
id: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export const SESSION_COOKIE = "moped_session";
|
||||
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
function getSecretKey() {
|
||||
return new TextEncoder().encode(getAuthSecret());
|
||||
}
|
||||
|
||||
export async function signSession(user: SessionUser) {
|
||||
return new SignJWT({ id: user.id })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_TTL_SECONDS}s`)
|
||||
.sign(getSecretKey());
|
||||
}
|
||||
|
||||
export async function setSessionCookie(user: SessionUser) {
|
||||
const token = await signSession(user);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_TTL_SECONDS,
|
||||
path: "/",
|
||||
priority: "high",
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionCookie() {
|
||||
(await cookies()).delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const result = await jwtVerify(token, getSecretKey());
|
||||
const payload = result.payload as unknown as SessionPayload;
|
||||
initDatabase();
|
||||
const user = db
|
||||
.prepare("SELECT id, name, email, role FROM users WHERE id = ?")
|
||||
.get(Number(payload.id)) as
|
||||
| { id: number; name: string; email: string; role: UserRole }
|
||||
| undefined;
|
||||
return user ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireSessionUser() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdminUser() {
|
||||
const user = await requireSessionUser();
|
||||
if (user.role !== "admin") redirect("/?error=Nur%20f%C3%BCr%20Administratoren.");
|
||||
return user;
|
||||
}
|
||||
|
||||
export function authenticateUser(email: string, password: string): SessionUser | null {
|
||||
initDatabase();
|
||||
const user = db
|
||||
.prepare("SELECT id, name, email, role, password_hash FROM users WHERE email = ?")
|
||||
.get(email.trim().toLowerCase()) as
|
||||
| (SessionUser & { password_hash: string })
|
||||
| undefined;
|
||||
|
||||
if (!user || !compareSync(password, user.password_hash)) return null;
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { hashSync } from "bcryptjs";
|
||||
import { getDatabasePath, getUploadDir } from "./env";
|
||||
|
||||
declare global {
|
||||
var __mopedDatabase: DatabaseSync | undefined;
|
||||
var __mopedDatabaseInitialized: boolean | undefined;
|
||||
}
|
||||
|
||||
export const db =
|
||||
globalThis.__mopedDatabase ??
|
||||
new DatabaseSync(getDatabasePath(), {
|
||||
enableForeignKeyConstraints: true,
|
||||
});
|
||||
|
||||
globalThis.__mopedDatabase = db;
|
||||
|
||||
function ensureSchema() {
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS motorcycles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nickname TEXT NOT NULL,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
vin TEXT,
|
||||
plate TEXT,
|
||||
current_km INTEGER NOT NULL DEFAULT 0 CHECK (current_km >= 0),
|
||||
notes TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('reparatur', 'tuev_hu', 'inspektion', 'reifenwechsel', 'oelwechsel', 'sonstiges')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
event_date TEXT NOT NULL,
|
||||
km INTEGER CHECK (km IS NULL OR km >= 0),
|
||||
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),
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
|
||||
trip_date TEXT NOT NULL,
|
||||
driver_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
start_km INTEGER NOT NULL CHECK (start_km >= 0),
|
||||
end_km INTEGER NOT NULL CHECK (end_km >= start_km),
|
||||
purpose TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
maintenance_event_id INTEGER NOT NULL UNIQUE REFERENCES maintenance_events(id) ON DELETE CASCADE,
|
||||
stored_name TEXT NOT NULL UNIQUE,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL CHECK (size_bytes > 0),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_motorcycle
|
||||
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_trips_motorcycle
|
||||
ON trips(motorcycle_id, trip_date DESC, id DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureInitialAdmin() {
|
||||
const row = db.prepare("SELECT COUNT(*) AS count FROM users").get() as
|
||||
| { count: number }
|
||||
| undefined;
|
||||
|
||||
if ((row?.count ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
const name = process.env.ADMIN_NAME?.trim();
|
||||
|
||||
if (!email || !password || !name) {
|
||||
throw new Error(
|
||||
"Keine Benutzer vorhanden. ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 10) {
|
||||
throw new Error("ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, 'admin')",
|
||||
).run(name, email, hashSync(password, 12));
|
||||
}
|
||||
|
||||
export function initDatabase() {
|
||||
if (globalThis.__mopedDatabaseInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
getUploadDir();
|
||||
ensureSchema();
|
||||
ensureInitialAdmin();
|
||||
globalThis.__mopedDatabaseInitialized = true;
|
||||
}
|
||||
|
||||
export function transaction<T>(work: () => T): T {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = work();
|
||||
db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import path from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
export function getAuthSecret() {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("AUTH_SECRET is required");
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function getDataDir() {
|
||||
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
export function getUploadDir() {
|
||||
const uploadDir = process.env.UPLOAD_DIR ?? path.join(getDataDir(), "uploads");
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
export function getDatabasePath() {
|
||||
return path.join(getDataDir(), "moped-tracker.sqlite");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function formatDate(value: string | null | undefined) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("de-DE").format(new Date(`${value}T00:00:00`));
|
||||
}
|
||||
|
||||
export function formatMoney(cents: number | null | undefined) {
|
||||
if (cents == null) return "—";
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
export function formatKm(value: number | null | undefined) {
|
||||
if (value == null) return "—";
|
||||
return `${new Intl.NumberFormat("de-DE").format(value)} km`;
|
||||
}
|
||||
|
||||
export function queryMessage(
|
||||
params: Record<string, string | string[] | undefined>,
|
||||
key: "error" | "success",
|
||||
) {
|
||||
const value = params[key];
|
||||
return typeof value === "string" ? value.slice(0, 300) : null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DueStatus } from "./types";
|
||||
|
||||
type DueInfo = {
|
||||
dueDate: string | null;
|
||||
dueKm: number | null;
|
||||
currentKm: number;
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function getDueStatus({ dueDate, dueKm, currentKm }: DueInfo): DueStatus {
|
||||
if (!dueDate && dueKm == null) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dueDate) {
|
||||
const due = new Date(dueDate);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
if (due.getTime() < now.getTime()) {
|
||||
return "overdue";
|
||||
}
|
||||
|
||||
if (due.getTime() - now.getTime() <= 30 * DAY_MS) {
|
||||
return "soon";
|
||||
}
|
||||
}
|
||||
|
||||
if (dueKm != null) {
|
||||
if (dueKm <= currentKm) {
|
||||
return "overdue";
|
||||
}
|
||||
|
||||
if (dueKm - currentKm <= 1000) {
|
||||
return "soon";
|
||||
}
|
||||
}
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export function statusClasses(status: DueStatus) {
|
||||
switch (status) {
|
||||
case "ok":
|
||||
return "status-ok";
|
||||
case "soon":
|
||||
return "status-soon";
|
||||
case "overdue":
|
||||
return "status-overdue";
|
||||
default:
|
||||
return "status-none";
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: DueStatus) {
|
||||
switch (status) {
|
||||
case "ok":
|
||||
return "OK";
|
||||
case "soon":
|
||||
return "Bald fällig";
|
||||
case "overdue":
|
||||
return "Überfällig";
|
||||
default:
|
||||
return "Kein Intervall";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const maintenanceTypes = [
|
||||
"reparatur",
|
||||
"tuev_hu",
|
||||
"inspektion",
|
||||
"reifenwechsel",
|
||||
"oelwechsel",
|
||||
"sonstiges",
|
||||
] as const;
|
||||
|
||||
export type MaintenanceType = (typeof maintenanceTypes)[number];
|
||||
export type UserRole = "admin" | "member";
|
||||
|
||||
export type SessionUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
export type MotorcycleSummary = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
plate: string | null;
|
||||
currentKm: number;
|
||||
};
|
||||
|
||||
export type DueStatus = "ok" | "soon" | "overdue" | "none";
|
||||
|
||||
export const maintenanceLabels: Record<MaintenanceType, string> = {
|
||||
reparatur: "Reparatur",
|
||||
tuev_hu: "TÜV / HU",
|
||||
inspektion: "Inspektion",
|
||||
reifenwechsel: "Reifenwechsel",
|
||||
oelwechsel: "Ölwechsel",
|
||||
sonstiges: "Sonstiges",
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { maintenanceTypes, type MaintenanceType, type UserRole } from "./types";
|
||||
|
||||
export function text(
|
||||
formData: FormData,
|
||||
key: string,
|
||||
options: { required?: boolean; max?: number } = {},
|
||||
) {
|
||||
const value = String(formData.get(key) ?? "").trim();
|
||||
if (options.required && !value) throw new Error(`${key} ist erforderlich.`);
|
||||
if (value.length > (options.max ?? 5000)) throw new Error(`${key} ist zu lang.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(
|
||||
formData: FormData,
|
||||
key: string,
|
||||
options: { required?: boolean; min?: number; max?: number } = {},
|
||||
) {
|
||||
const raw = String(formData.get(key) ?? "").trim();
|
||||
if (!raw && !options.required) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`${key} muss eine ganze Zahl sein.`);
|
||||
if (options.min != null && value < options.min) throw new Error(`${key} ist zu klein.`);
|
||||
if (options.max != null && value > options.max) throw new Error(`${key} ist zu groß.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function dateValue(formData: FormData, key: string, required = false) {
|
||||
const value = text(formData, key, { required, max: 10 });
|
||||
if (!value) return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) {
|
||||
throw new Error(`${key} ist kein gültiges Datum.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function emailValue(formData: FormData, key = "email") {
|
||||
const value = text(formData, key, { required: true, max: 254 }).toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) throw new Error("Ungültige E-Mail-Adresse.");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function passwordValue(formData: FormData, key = "password") {
|
||||
const value = String(formData.get(key) ?? "");
|
||||
if (value.length < 10 || value.length > 200) {
|
||||
throw new Error("Das Passwort muss 10 bis 200 Zeichen lang sein.");
|
||||
}
|
||||
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.");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function idValue(formData: FormData, key = "id") {
|
||||
const value = integer(formData, key, { required: true, min: 1 });
|
||||
if (value == null) throw new Error("Ungültige ID.");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user