From de193bc7839f7ca2b049d09b70173819f91d8121 Mon Sep 17 00:00:00 2001 From: Denis Urs Rudolph Date: Fri, 5 Dec 2025 22:17:50 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Fahrrad=20Verschlei=C3=9Fte?= =?UTF-8?q?ile=20Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Next.js SPA mit Bun Runtime - Prisma mit SQLite Datenbank - Vollständige CRUD-Operationen für Fahrräder, Verschleißteile und Wartungshistorie - Warnsystem für bevorstehende Wartungen - Statistik-Features (Gesamtkosten, durchschnittliche Lebensdauer) - Zod-Validierung für alle API-Requests - Umfassende Test-Suite (41 Tests) --- .eslintrc.json | 4 + .gitignore | 44 + ...cursor_plan_f_r_fahrrad_verschlei_teile.md | 6036 +++++++++++++++++ README.md | 102 + __tests__/api/bikes.test.ts | 178 + __tests__/api/maintenance.test.ts | 154 + __tests__/api/parts.test.ts | 185 + __tests__/lib/utils.test.ts | 330 + __tests__/lib/validations.test.ts | 198 + app/api/bikes/[id]/parts/route.ts | 87 + app/api/bikes/[id]/route.ts | 118 + app/api/bikes/route.ts | 66 + app/api/parts/[id]/maintenance/route.ts | 72 + app/api/parts/[id]/route.ts | 114 + app/bikes/[id]/page.tsx | 50 + app/components/AlertBadge.tsx | 14 + app/components/BikeCard.tsx | 92 + app/components/BikeDetail.tsx | 89 + app/components/BikeForm.tsx | 182 + app/components/MaintenanceForm.tsx | 211 + app/components/MaintenanceTimeline.tsx | 93 + app/components/StatsCard.tsx | 56 + app/components/WearPartForm.tsx | 321 + app/components/WearPartList.tsx | 193 + app/globals.css | 22 + app/layout.tsx | 20 + app/page.tsx | 92 + bun.lock | 1055 +++ lib/prisma.ts | 10 + lib/utils.ts | 85 + lib/validations.ts | 71 + next.config.js | 5 + package.json | 39 + postcss.config.js | 7 + prisma/schema.prisma | 55 + tailwind.config.ts | 20 + tsconfig.json | 28 + types/index.ts | 15 + vitest.config.ts | 28 + 39 files changed, 10541 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 CursorBuildStory/cursor_plan_f_r_fahrrad_verschlei_teile.md create mode 100644 README.md create mode 100644 __tests__/api/bikes.test.ts create mode 100644 __tests__/api/maintenance.test.ts create mode 100644 __tests__/api/parts.test.ts create mode 100644 __tests__/lib/utils.test.ts create mode 100644 __tests__/lib/validations.test.ts create mode 100644 app/api/bikes/[id]/parts/route.ts create mode 100644 app/api/bikes/[id]/route.ts create mode 100644 app/api/bikes/route.ts create mode 100644 app/api/parts/[id]/maintenance/route.ts create mode 100644 app/api/parts/[id]/route.ts create mode 100644 app/bikes/[id]/page.tsx create mode 100644 app/components/AlertBadge.tsx create mode 100644 app/components/BikeCard.tsx create mode 100644 app/components/BikeDetail.tsx create mode 100644 app/components/BikeForm.tsx create mode 100644 app/components/MaintenanceForm.tsx create mode 100644 app/components/MaintenanceTimeline.tsx create mode 100644 app/components/StatsCard.tsx create mode 100644 app/components/WearPartForm.tsx create mode 100644 app/components/WearPartList.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 bun.lock create mode 100644 lib/prisma.ts create mode 100644 lib/utils.ts create mode 100644 lib/validations.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/index.ts create mode 100644 vitest.config.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f18272b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": "next/core-web-vitals" +} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c37a3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma +/prisma/dev.db +/prisma/dev.db-journal + +# bun +bun.lockb + diff --git a/CursorBuildStory/cursor_plan_f_r_fahrrad_verschlei_teile.md b/CursorBuildStory/cursor_plan_f_r_fahrrad_verschlei_teile.md new file mode 100644 index 0000000..3583ec6 --- /dev/null +++ b/CursorBuildStory/cursor_plan_f_r_fahrrad_verschlei_teile.md @@ -0,0 +1,6036 @@ +# Plan für fahrrad verschleißteile app +_Exported on 5.12.2025 at 22:09:28 MEZ from Cursor (2.1.49)_ + +--- + +**User** + +ich möchte ein Tool zur Erfasung der Verschleißteile (Kette, Bremsbeläge, Reifen, etc. ) an einem Fahrrad. Es sollen verschiedene Fahrräder unterstützt und erfasst werden. Das ganze soll eine Single-Page-Application auf Basis von Next.js mit einer Datenbasis in Prisma. Auth wird nicht benötigt. Die Laufzeitumgebung soll bun sein. Erstelle einen Plan + +--- + +**Cursor** + + +Kläre ein paar Punkte, bevor ich den Plan erstelle: + + + +Prüfe die aktuelle Projektstruktur: + + + +Erstelle den Plan für die Fahrrad-Verschleißteile-Verwaltung: + + +--- + +**User** + +Füge noch zod und ein Testframework als Pakete hinzu. + +--- + +**Cursor** + + +Aktualisiere den Plan: Zod für Validierung und ein Testframework (Vitest) hinzufügen. + + +--- + +**User** + +Füge noch das Erstellen von Testfällen als weiteren Schritt hinzu + +--- + +**Cursor** + + +Lese den aktuellen Plan, um den Schritt für Testfälle hinzuzufügen: + + + +Hinzufügen eines Schritts zum Erstellen von Testfällen: + + +--- + +**User** + +Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself. + +To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos. + +--- + +**Cursor** + + +Prüfe die vorhandenen Todos und beginne mit der Implementierung. + + + +Beginne mit der Implementierung. Starte mit dem Projekt-Setup. + + + +Initialisiere das Projekt manuell, da der Verzeichnisname Großbuchstaben enthält: + + + +Erstelle die Projektstruktur manuell: + + +```json +{ + "name": "bike-wear-parts-tracker", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "db:push": "prisma db push", + "db:generate": "prisma generate", + "db:studio": "prisma studio", + "test": "vitest", + "test:ui": "vitest --ui" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "next": "^14.2.5", + "@prisma/client": "^5.19.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.5.4", + "@types/node": "^22.5.0", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "prisma": "^5.19.1", + "tailwindcss": "^3.4.7", + "postcss": "^8.4.41", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.5", + "vitest": "^2.0.3", + "@vitest/ui": "^2.0.3" + } +} + +``` + +```jsonc +{ + "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": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + +``` + +```javascript +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig + +``` + +```typescript +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: "var(--background)", + foreground: "var(--foreground)", + }, + }, + }, + plugins: [], +}; +export default config; + +``` + +```javascript +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +``` + +```jsonc +{ + "extends": "next/core-web-vitals" +} + +``` + +```typescript +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './'), + }, + }, +}) + +``` + + +Erstelle die grundlegende App-Struktur und fahre mit dem Prisma-Schema fort: + + +```typescriptreact +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Fahrrad Verschleißteile Tracker", + description: "Verwaltung von Verschleißteilen an Fahrrädern", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} + +``` + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; +} + +``` + +```plaintext +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model Bike { + id String @id @default(cuid()) + name String + brand String? + model String? + purchaseDate DateTime? + notes String? + wearParts WearPart[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model WearPart { + id String @id @default(cuid()) + bikeId String + bike Bike @relation(fields: [bikeId], references: [id], onDelete: Cascade) + type WearPartType + brand String? + model String? + installDate DateTime + installMileage Int @default(0) + serviceInterval Int // in km + status WearPartStatus @default(ACTIVE) + cost Float? + notes String? + maintenanceHistory MaintenanceHistory[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model MaintenanceHistory { + id String @id @default(cuid()) + wearPartId String + wearPart WearPart @relation(fields: [wearPartId], references: [id], onDelete: Cascade) + date DateTime + mileage Int + action MaintenanceAction + notes String? + cost Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum WearPartType { + CHAIN + BRAKE_PADS + TIRE + CASSETTE + CHAINRING + DERAILLEUR + BRAKE_CABLE + SHIFT_CABLE + BRAKE_ROTOR + PEDAL + CRANKSET + BOTTOM_BRACKET + HEADSET + WHEEL + HUB + SPOKE + OTHER +} + +enum WearPartStatus { + ACTIVE + NEEDS_SERVICE + REPLACED + INACTIVE +} + +enum MaintenanceAction { + INSTALL + REPLACE + SERVICE + CHECK + ADJUST +} + +``` + + +SQLite unterstützt keine Enums. Umstellen auf String-Felder mit Constraints: + + +```plaintext +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model Bike { + id String @id @default(cuid()) + name String + brand String? + model String? + purchaseDate DateTime? + notes String? + wearParts WearPart[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model WearPart { + id String @id @default(cuid()) + bikeId String + bike Bike @relation(fields: [bikeId], references: [id], onDelete: Cascade) + type String // CHAIN, BRAKE_PADS, TIRE, CASSETTE, CHAINRING, DERAILLEUR, etc. + brand String? + model String? + installDate DateTime + installMileage Int @default(0) + serviceInterval Int // in km + status String @default("ACTIVE") // ACTIVE, NEEDS_SERVICE, REPLACED, INACTIVE + cost Float? + notes String? + maintenanceHistory MaintenanceHistory[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model MaintenanceHistory { + id String @id @default(cuid()) + wearPartId String + wearPart WearPart @relation(fields: [wearPartId], references: [id], onDelete: Cascade) + date DateTime + mileage Int + action String // INSTALL, REPLACE, SERVICE, CHECK, ADJUST + notes String? + cost Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + + +``` + + +Erstelle den Prisma Client und die Zod-Validierungsschemas: + + +```typescript +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma + +``` + +```typescript +import { z } from 'zod' + +export const WearPartType = z.enum([ + 'CHAIN', + 'BRAKE_PADS', + 'TIRE', + 'CASSETTE', + 'CHAINRING', + 'DERAILLEUR', + 'BRAKE_CABLE', + 'SHIFT_CABLE', + 'BRAKE_ROTOR', + 'PEDAL', + 'CRANKSET', + 'BOTTOM_BRACKET', + 'HEADSET', + 'WHEEL', + 'HUB', + 'SPOKE', + 'OTHER', +]) + +export const WearPartStatus = z.enum([ + 'ACTIVE', + 'NEEDS_SERVICE', + 'REPLACED', + 'INACTIVE', +]) + +export const MaintenanceAction = z.enum([ + 'INSTALL', + 'REPLACE', + 'SERVICE', + 'CHECK', + 'ADJUST', +]) + +export const bikeSchema = z.object({ + name: z.string().min(1, 'Name ist erforderlich'), + brand: z.string().optional(), + model: z.string().optional(), + purchaseDate: z.string().datetime().optional().or(z.date().optional()), + notes: z.string().optional(), +}) + +export const wearPartSchema = z.object({ + bikeId: z.string().min(1, 'Fahrrad-ID ist erforderlich'), + type: WearPartType, + brand: z.string().optional(), + model: z.string().optional(), + installDate: z.string().datetime().or(z.date()), + installMileage: z.number().int().min(0).default(0), + serviceInterval: z.number().int().min(1, 'Service-Intervall muss mindestens 1 km sein'), + status: WearPartStatus.default('ACTIVE'), + cost: z.number().positive().optional(), + notes: z.string().optional(), +}) + +export const maintenanceHistorySchema = z.object({ + wearPartId: z.string().min(1, 'Verschleißteil-ID ist erforderlich'), + date: z.string().datetime().or(z.date()), + mileage: z.number().int().min(0), + action: MaintenanceAction, + notes: z.string().optional(), + cost: z.number().positive().optional(), +}) + +export type BikeInput = z.infer +export type WearPartInput = z.infer +export type MaintenanceHistoryInput = z.infer + +``` + +```typescript +import { Bike, WearPart, MaintenanceHistory } from '@prisma/client' + +export type BikeWithParts = Bike & { + wearParts: WearPart[] +} + +export type WearPartWithHistory = WearPart & { + maintenanceHistory: MaintenanceHistory[] + bike: Bike +} + +export type MaintenanceHistoryWithPart = MaintenanceHistory & { + wearPart: WearPart +} + +``` + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { bikeSchema } from '@/lib/validations' + +export async function GET() { + try { + const bikes = await prisma.bike.findMany({ + include: { + wearParts: { + include: { + maintenanceHistory: { + orderBy: { + date: 'desc', + }, + take: 1, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + return NextResponse.json(bikes) + } catch (error) { + console.error('Error fetching bikes:', error) + return NextResponse.json( + { error: 'Fehler beim Laden der Fahrräder' }, + { status: 500 } + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const validatedData = bikeSchema.parse(body) + + const bike = await prisma.bike.create({ + data: { + name: validatedData.name, + brand: validatedData.brand, + model: validatedData.model, + purchaseDate: validatedData.purchaseDate + ? new Date(validatedData.purchaseDate) + : null, + notes: validatedData.notes, + }, + }) + + return NextResponse.json(bike, { status: 201 }) + } catch (error) { + console.error('Error creating bike:', error) + if (error instanceof Error && error.name === 'ZodError') { + return NextResponse.json( + { error: 'Ungültige Daten', details: error }, + { status: 400 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Erstellen des Fahrrads' }, + { status: 500 } + ) + } +} + +``` + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { bikeSchema } from '@/lib/validations' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const bike = await prisma.bike.findUnique({ + where: { id: params.id }, + include: { + wearParts: { + include: { + maintenanceHistory: { + orderBy: { + date: 'desc', + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + }, + }) + + if (!bike) { + return NextResponse.json( + { error: 'Fahrrad nicht gefunden' }, + { status: 404 } + ) + } + + return NextResponse.json(bike) + } catch (error) { + console.error('Error fetching bike:', error) + return NextResponse.json( + { error: 'Fehler beim Laden des Fahrrads' }, + { status: 500 } + ) + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const validatedData = bikeSchema.parse(body) + + const bike = await prisma.bike.update({ + where: { id: params.id }, + data: { + name: validatedData.name, + brand: validatedData.brand, + model: validatedData.model, + purchaseDate: validatedData.purchaseDate + ? new Date(validatedData.purchaseDate) + : null, + notes: validatedData.notes, + }, + }) + + return NextResponse.json(bike) + } catch (error) { + console.error('Error updating bike:', error) + if (error instanceof Error && error.name === 'ZodError') { + return NextResponse.json( + { error: 'Ungültige Daten', details: error }, + { status: 400 } + ) + } + if ( + error instanceof Error && + error.message.includes('Record to update does not exist') + ) { + return NextResponse.json( + { error: 'Fahrrad nicht gefunden' }, + { status: 404 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Aktualisieren des Fahrrads' }, + { status: 500 } + ) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await prisma.bike.delete({ + where: { id: params.id }, + }) + + return NextResponse.json({ message: 'Fahrrad gelöscht' }) + } catch (error) { + console.error('Error deleting bike:', error) + if ( + error instanceof Error && + error.message.includes('Record to delete does not exist') + ) { + return NextResponse.json( + { error: 'Fahrrad nicht gefunden' }, + { status: 404 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Löschen des Fahrrads' }, + { status: 500 } + ) + } +} + +``` + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { wearPartSchema } from '@/lib/validations' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const parts = await prisma.wearPart.findMany({ + where: { bikeId: params.id }, + include: { + maintenanceHistory: { + orderBy: { + date: 'desc', + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return NextResponse.json(parts) + } catch (error) { + console.error('Error fetching parts:', error) + return NextResponse.json( + { error: 'Fehler beim Laden der Verschleißteile' }, + { status: 500 } + ) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const validatedData = wearPartSchema.parse({ + ...body, + bikeId: params.id, + }) + + const part = await prisma.wearPart.create({ + data: { + bikeId: params.id, + type: validatedData.type, + brand: validatedData.brand, + model: validatedData.model, + installDate: new Date(validatedData.installDate), + installMileage: validatedData.installMileage, + serviceInterval: validatedData.serviceInterval, + status: validatedData.status, + cost: validatedData.cost, + notes: validatedData.notes, + }, + }) + + // Erstelle automatisch einen MaintenanceHistory-Eintrag für die Installation + await prisma.maintenanceHistory.create({ + data: { + wearPartId: part.id, + date: new Date(validatedData.installDate), + mileage: validatedData.installMileage, + action: 'INSTALL', + notes: validatedData.notes, + cost: validatedData.cost, + }, + }) + + return NextResponse.json(part, { status: 201 }) + } catch (error) { + console.error('Error creating part:', error) + if (error instanceof Error && error.name === 'ZodError') { + return NextResponse.json( + { error: 'Ungültige Daten', details: error }, + { status: 400 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Erstellen des Verschleißteils' }, + { status: 500 } + ) + } +} + +``` + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { wearPartSchema } from '@/lib/validations' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const part = await prisma.wearPart.findUnique({ + where: { id: params.id }, + include: { + bike: true, + maintenanceHistory: { + orderBy: { + date: 'desc', + }, + }, + }, + }) + + if (!part) { + return NextResponse.json( + { error: 'Verschleißteil nicht gefunden' }, + { status: 404 } + ) + } + + return NextResponse.json(part) + } catch (error) { + console.error('Error fetching part:', error) + return NextResponse.json( + { error: 'Fehler beim Laden des Verschleißteils' }, + { status: 500 } + ) + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const validatedData = wearPartSchema.parse(body) + + const part = await prisma.wearPart.update({ + where: { id: params.id }, + data: { + type: validatedData.type, + brand: validatedData.brand, + model: validatedData.model, + installDate: new Date(validatedData.installDate), + installMileage: validatedData.installMileage, + serviceInterval: validatedData.serviceInterval, + status: validatedData.status, + cost: validatedData.cost, + notes: validatedData.notes, + }, + }) + + return NextResponse.json(part) + } catch (error) { + console.error('Error updating part:', error) + if (error instanceof Error && error.name === 'ZodError') { + return NextResponse.json( + { error: 'Ungültige Daten', details: error }, + { status: 400 } + ) + } + if ( + error instanceof Error && + error.message.includes('Record to update does not exist') + ) { + return NextResponse.json( + { error: 'Verschleißteil nicht gefunden' }, + { status: 404 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Aktualisieren des Verschleißteils' }, + { status: 500 } + ) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await prisma.wearPart.delete({ + where: { id: params.id }, + }) + + return NextResponse.json({ message: 'Verschleißteil gelöscht' }) + } catch (error) { + console.error('Error deleting part:', error) + if ( + error instanceof Error && + error.message.includes('Record to delete does not exist') + ) { + return NextResponse.json( + { error: 'Verschleißteil nicht gefunden' }, + { status: 404 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Löschen des Verschleißteils' }, + { status: 500 } + ) + } +} + +``` + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { maintenanceHistorySchema } from '@/lib/validations' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const history = await prisma.maintenanceHistory.findMany({ + where: { wearPartId: params.id }, + orderBy: { + date: 'desc', + }, + }) + + return NextResponse.json(history) + } catch (error) { + console.error('Error fetching maintenance history:', error) + return NextResponse.json( + { error: 'Fehler beim Laden der Wartungshistorie' }, + { status: 500 } + ) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const validatedData = maintenanceHistorySchema.parse({ + ...body, + wearPartId: params.id, + }) + + const history = await prisma.maintenanceHistory.create({ + data: { + wearPartId: params.id, + date: new Date(validatedData.date), + mileage: validatedData.mileage, + action: validatedData.action, + notes: validatedData.notes, + cost: validatedData.cost, + }, + }) + + // Wenn die Aktion REPLACE ist, setze den Status des Verschleißteils auf REPLACED + if (validatedData.action === 'REPLACE') { + await prisma.wearPart.update({ + where: { id: params.id }, + data: { status: 'REPLACED' }, + }) + } + + return NextResponse.json(history, { status: 201 }) + } catch (error) { + console.error('Error creating maintenance history:', error) + if (error instanceof Error && error.name === 'ZodError') { + return NextResponse.json( + { error: 'Ungültige Daten', details: error }, + { status: 400 } + ) + } + return NextResponse.json( + { error: 'Fehler beim Erstellen des Wartungseintrags' }, + { status: 500 } + ) + } +} + +``` + +```typescript +import { WearPart, MaintenanceHistory } from '@prisma/client' + +export function calculateServiceStatus( + part: WearPart & { maintenanceHistory: MaintenanceHistory[] } +): { + status: 'OK' | 'WARNING' | 'CRITICAL' + remainingKm: number + percentageUsed: number +} { + const latestMaintenance = part.maintenanceHistory[0] + const currentMileage = latestMaintenance?.mileage ?? part.installMileage + const kmSinceInstall = currentMileage - part.installMileage + const remainingKm = part.serviceInterval - kmSinceInstall + const percentageUsed = (kmSinceInstall / part.serviceInterval) * 100 + + let status: 'OK' | 'WARNING' | 'CRITICAL' = 'OK' + if (percentageUsed >= 90) { + status = 'CRITICAL' + } else if (percentageUsed >= 75) { + status = 'WARNING' + } + + return { + status, + remainingKm: Math.max(0, remainingKm), + percentageUsed: Math.min(100, percentageUsed), + } +} + +export function calculateTotalCosts( + parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[] +): number { + return parts.reduce((total, part) => { + const partCost = part.cost ?? 0 + const maintenanceCost = part.maintenanceHistory.reduce( + (sum, m) => sum + (m.cost ?? 0), + 0 + ) + return total + partCost + maintenanceCost + }, 0) +} + +export function calculateAverageLifespan( + parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[] +): number | null { + const replacedParts = parts.filter( + (p) => p.status === 'REPLACED' && p.maintenanceHistory.length > 0 + ) + + if (replacedParts.length === 0) return null + + const totalKm = replacedParts.reduce((sum, part) => { + const installHistory = part.maintenanceHistory.find( + (h) => h.action === 'INSTALL' + ) + const replaceHistory = part.maintenanceHistory.find( + (h) => h.action === 'REPLACE' + ) + + if (installHistory && replaceHistory) { + return sum + (replaceHistory.mileage - installHistory.mileage) + } + return sum + }, 0) + + return totalKm / replacedParts.length +} + +export function formatDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date + return d.toLocaleDateString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +export function formatCurrency(amount: number | null | undefined): string { + if (amount === null || amount === undefined) return '0,00 €' + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + }).format(amount) +} + +``` + +```typescriptreact +'use client' + +import { useEffect, useState } from 'react' +import { BikeWithParts } from '@/types' +import BikeCard from '@/app/components/BikeCard' +import BikeForm from '@/app/components/BikeForm' +import StatsCard from '@/app/components/StatsCard' + +export default function Home() { + const [bikes, setBikes] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + + useEffect(() => { + fetchBikes() + }, []) + + const fetchBikes = async () => { + try { + const response = await fetch('/api/bikes') + if (response.ok) { + const data = await response.json() + setBikes(data) + } + } catch (error) { + console.error('Error fetching bikes:', error) + } finally { + setLoading(false) + } + } + + const handleBikeCreated = () => { + setShowForm(false) + fetchBikes() + } + + const handleBikeDeleted = () => { + fetchBikes() + } + + if (loading) { + return ( +
+
Lade...
+
+ ) + } + + return ( +
+
+
+

+ Fahrrad Verschleißteile Tracker +

+ +
+ + {showForm && ( +
+ setShowForm(false)} /> +
+ )} + + + +
+ {bikes.length === 0 ? ( +
+

Noch keine Fahrräder erfasst.

+

Klicken Sie auf "Neues Fahrrad" um zu beginnen.

+
+ ) : ( + bikes.map((bike) => ( + + )) + )} +
+
+
+ ) +} + +``` + +```typescriptreact +'use client' + +import { BikeWithParts } from '@/types' +import { useState } from 'react' +import Link from 'next/link' +import { calculateServiceStatus } from '@/lib/utils' +import AlertBadge from './AlertBadge' + +interface BikeCardProps { + bike: BikeWithParts + onDelete: () => void +} + +export default function BikeCard({ bike, onDelete }: BikeCardProps) { + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + if (!confirm('Möchten Sie dieses Fahrrad wirklich löschen?')) { + return + } + + setIsDeleting(true) + try { + const response = await fetch(`/api/bikes/${bike.id}`, { + method: 'DELETE', + }) + + if (response.ok) { + onDelete() + } else { + alert('Fehler beim Löschen des Fahrrads') + } + } catch (error) { + console.error('Error deleting bike:', error) + alert('Fehler beim Löschen des Fahrrads') + } finally { + setIsDeleting(false) + } + } + + const activeParts = bike.wearParts.filter((p) => p.status === 'ACTIVE') + const needsServiceParts = activeParts.filter((part) => { + const serviceStatus = calculateServiceStatus(part) + return serviceStatus.status !== 'OK' + }) + + return ( +
+
+
+

{bike.name}

+ {bike.brand && bike.model && ( +

+ {bike.brand} {bike.model} +

+ )} +
+ {needsServiceParts.length > 0 && ( + + )} +
+ +
+

+ {activeParts.length} aktive Verschleißteile +

+ {needsServiceParts.length > 0 && ( +

+ {needsServiceParts.length} benötigen Wartung +

+ )} +
+ +
+ + Details + + +
+
+ ) +} + +``` + +```typescriptreact +'use client' + +import { useState } from 'react' +import { bikeSchema } from '@/lib/validations' + +interface BikeFormProps { + onSuccess: () => void + onCancel: () => void +} + +export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) { + const [formData, setFormData] = useState({ + name: '', + brand: '', + model: '', + purchaseDate: '', + notes: '', + }) + const [errors, setErrors] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setErrors({}) + setIsSubmitting(true) + + try { + const validatedData = bikeSchema.parse({ + ...formData, + purchaseDate: formData.purchaseDate || undefined, + }) + + const response = await fetch('/api/bikes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(validatedData), + }) + + if (response.ok) { + onSuccess() + setFormData({ + name: '', + brand: '', + model: '', + purchaseDate: '', + notes: '', + }) + } else { + const errorData = await response.json() + if (errorData.details) { + const zodErrors: Record = {} + errorData.details.errors?.forEach((err: any) => { + if (err.path) { + zodErrors[err.path[0]] = err.message + } + }) + setErrors(zodErrors) + } else { + alert('Fehler beim Erstellen des Fahrrads') + } + } + } catch (error: any) { + if (error.errors) { + const zodErrors: Record = {} + error.errors.forEach((err: any) => { + if (err.path) { + zodErrors[err.path[0]] = err.message + } + }) + setErrors(zodErrors) + } else { + console.error('Error creating bike:', error) + alert('Fehler beim Erstellen des Fahrrads') + } + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

Neues Fahrrad hinzufügen

+
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+
+ + + setFormData({ ...formData, brand: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + + setFormData({ ...formData, model: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ +
+ + + setFormData({ ...formData, purchaseDate: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ +