Initial commit: Fahrrad Verschleißteile Tracker
- 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)
This commit is contained in:
10
lib/prisma.ts
Normal file
10
lib/prisma.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
|
||||
85
lib/utils.ts
Normal file
85
lib/utils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
}
|
||||
|
||||
71
lib/validations.ts
Normal file
71
lib/validations.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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<typeof bikeSchema>
|
||||
export type WearPartInput = z.infer<typeof wearPartSchema>
|
||||
export type MaintenanceHistoryInput = z.infer<typeof maintenanceHistorySchema>
|
||||
|
||||
Reference in New Issue
Block a user