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:
Denis Urs Rudolph
2025-12-05 22:17:50 +01:00
commit de193bc783
39 changed files with 10541 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
interface AlertBadgeProps {
count: number
}
export default function AlertBadge({ count }: AlertBadgeProps) {
if (count === 0) return null
return (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
{count}
</span>
)
}

View File

@@ -0,0 +1,92 @@
'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 (
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900">{bike.name}</h2>
{bike.brand && bike.model && (
<p className="text-gray-600 mt-1">
{bike.brand} {bike.model}
</p>
)}
</div>
{needsServiceParts.length > 0 && (
<AlertBadge count={needsServiceParts.length} />
)}
</div>
<div className="mb-4">
<p className="text-sm text-gray-500">
{activeParts.length} aktive Verschleißteile
</p>
{needsServiceParts.length > 0 && (
<p className="text-sm text-orange-600 font-medium mt-1">
{needsServiceParts.length} benötigen Wartung
</p>
)}
</div>
<div className="flex gap-2 mt-4">
<Link
href={`/bikes/${bike.id}`}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-center rounded-lg hover:bg-blue-700 transition-colors"
>
Details
</Link>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{isDeleting ? 'Löschen...' : 'Löschen'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { BikeWithParts } from '@/types'
import { useState } from 'react'
import Link from 'next/link'
import WearPartList from './WearPartList'
import WearPartForm from './WearPartForm'
interface BikeDetailProps {
bike: BikeWithParts
onUpdate: () => void
}
export default function BikeDetail({ bike, onUpdate }: BikeDetailProps) {
const [showPartForm, setShowPartForm] = useState(false)
return (
<main className="min-h-screen p-8 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Link
href="/"
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-6"
>
Zurück zur Übersicht
</Link>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-4">{bike.name}</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{bike.brand && (
<div>
<span className="text-gray-500">Marke:</span>
<p className="font-medium">{bike.brand}</p>
</div>
)}
{bike.model && (
<div>
<span className="text-gray-500">Modell:</span>
<p className="font-medium">{bike.model}</p>
</div>
)}
{bike.purchaseDate && (
<div>
<span className="text-gray-500">Kaufdatum:</span>
<p className="font-medium">
{new Date(bike.purchaseDate).toLocaleDateString('de-DE')}
</p>
</div>
)}
</div>
{bike.notes && (
<div className="mt-4">
<span className="text-gray-500">Notizen:</span>
<p className="mt-1">{bike.notes}</p>
</div>
)}
</div>
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-semibold text-gray-900">
Verschleißteile
</h2>
<button
onClick={() => setShowPartForm(!showPartForm)}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{showPartForm ? 'Abbrechen' : '+ Neues Verschleißteil'}
</button>
</div>
{showPartForm && (
<div className="mb-6">
<WearPartForm
bikeId={bike.id}
onSuccess={() => {
setShowPartForm(false)
onUpdate()
}}
onCancel={() => setShowPartForm(false)}
/>
</div>
)}
<WearPartList bikeId={bike.id} parts={bike.wearParts} onUpdate={onUpdate} />
</div>
</main>
)
}

182
app/components/BikeForm.tsx Normal file
View File

@@ -0,0 +1,182 @@
'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<Record<string, string>>({})
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<string, string> = {}
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<string, string> = {}
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 (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-semibold mb-4">Neues Fahrrad hinzufügen</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) =>
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 && (
<p className="text-red-600 text-sm mt-1">{errors.name}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Marke
</label>
<input
type="text"
value={formData.brand}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Modell
</label>
<input
type="text"
value={formData.model}
onChange={(e) =>
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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kaufdatum
</label>
<input
type="date"
value={formData.purchaseDate}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen
</label>
<textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Erstellen...' : 'Erstellen'}
</button>
<button
type="button"
onClick={onCancel}
className="px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
Abbrechen
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,211 @@
'use client'
import { useState } from 'react'
import { maintenanceHistorySchema, MaintenanceAction } from '@/lib/validations'
interface MaintenanceFormProps {
partId: string
onSuccess: () => void
onCancel: () => void
}
const maintenanceActions = ['INSTALL', 'REPLACE', 'SERVICE', 'CHECK', 'ADJUST']
export default function MaintenanceForm({
partId,
onSuccess,
onCancel,
}: MaintenanceFormProps) {
const [formData, setFormData] = useState({
date: new Date().toISOString().split('T')[0],
mileage: 0,
action: 'SERVICE',
notes: '',
cost: '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors({})
setIsSubmitting(true)
try {
const validatedData = maintenanceHistorySchema.parse({
wearPartId: partId,
date: formData.date,
mileage: Number(formData.mileage),
action: formData.action,
notes: formData.notes || undefined,
cost: formData.cost ? Number(formData.cost) : undefined,
})
const response = await fetch(`/api/parts/${partId}/maintenance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(validatedData),
})
if (response.ok) {
onSuccess()
setFormData({
date: new Date().toISOString().split('T')[0],
mileage: 0,
action: 'SERVICE',
notes: '',
cost: '',
})
} else {
const errorData = await response.json()
if (errorData.details) {
const zodErrors: Record<string, string> = {}
errorData.details.errors?.forEach((err: any) => {
if (err.path) {
zodErrors[err.path[0]] = err.message
}
})
setErrors(zodErrors)
} else {
alert('Fehler beim Erstellen des Wartungseintrags')
}
}
} catch (error: any) {
if (error.errors) {
const zodErrors: Record<string, string> = {}
error.errors.forEach((err: any) => {
if (err.path) {
zodErrors[err.path[0]] = err.message
}
})
setErrors(zodErrors)
} else {
console.error('Error creating maintenance:', error)
alert('Fehler beim Erstellen des Wartungseintrags')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h4 className="font-semibold mb-3">Neuer Wartungseintrag</h4>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Datum *
</label>
<input
type="date"
value={formData.date}
onChange={(e) =>
setFormData({ ...formData, date: e.target.value })
}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
{errors.date && (
<p className="text-red-600 text-xs mt-1">{errors.date}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Kilometerstand *
</label>
<input
type="number"
value={formData.mileage}
onChange={(e) =>
setFormData({ ...formData, mileage: Number(e.target.value) })
}
min="0"
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
{errors.mileage && (
<p className="text-red-600 text-xs mt-1">{errors.mileage}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Aktion *
</label>
<select
value={formData.action}
onChange={(e) =>
setFormData({ ...formData, action: e.target.value })
}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
{maintenanceActions.map((action) => (
<option key={action} value={action}>
{action.replace(/_/g, ' ')}
</option>
))}
</select>
{errors.action && (
<p className="text-red-600 text-xs mt-1">{errors.action}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Kosten ()
</label>
<input
type="number"
step="0.01"
value={formData.cost}
onChange={(e) =>
setFormData({ ...formData, cost: e.target.value })
}
min="0"
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Notizen
</label>
<textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
rows={2}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Erstellen...' : 'Erstellen'}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-300 text-gray-700 text-sm rounded hover:bg-gray-400 transition-colors"
>
Abbrechen
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { MaintenanceHistory } from '@prisma/client'
import { formatDate, formatCurrency } from '@/lib/utils'
import { useState } from 'react'
import MaintenanceForm from './MaintenanceForm'
interface MaintenanceTimelineProps {
partId: string
history: MaintenanceHistory[]
onUpdate: () => void
}
const actionLabels: Record<string, string> = {
INSTALL: 'Installiert',
REPLACE: 'Ersetzt',
SERVICE: 'Gewartet',
CHECK: 'Geprüft',
ADJUST: 'Eingestellt',
}
export default function MaintenanceTimeline({
partId,
history,
onUpdate,
}: MaintenanceTimelineProps) {
const [showForm, setShowForm] = useState(false)
return (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Wartungshistorie
</h3>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
{showForm ? 'Abbrechen' : '+ Neuer Eintrag'}
</button>
</div>
{showForm && (
<div className="mb-4">
<MaintenanceForm
partId={partId}
onSuccess={() => {
setShowForm(false)
onUpdate()
}}
onCancel={() => setShowForm(false)}
/>
</div>
)}
{history.length === 0 ? (
<p className="text-gray-500 text-sm">Noch keine Wartungseinträge.</p>
) : (
<div className="space-y-3">
{history.map((entry) => (
<div
key={entry.id}
className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-gray-900">
{actionLabels[entry.action] || entry.action}
</span>
<span className="text-sm text-gray-500">
{formatDate(entry.date)}
</span>
</div>
<div className="text-sm text-gray-600">
<span>Kilometerstand: {entry.mileage} km</span>
{entry.cost && (
<span className="ml-4">
Kosten: {formatCurrency(entry.cost)}
</span>
)}
</div>
{entry.notes && (
<p className="text-sm text-gray-600 mt-2">{entry.notes}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { BikeWithParts } from '@/types'
import { calculateTotalCosts, calculateAverageLifespan } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
interface StatsCardProps {
bikes: BikeWithParts[]
}
export default function StatsCard({ bikes }: StatsCardProps) {
const allParts = bikes.flatMap((bike) => bike.wearParts)
const totalCosts = calculateTotalCosts(allParts)
const avgLifespan = calculateAverageLifespan(allParts)
const totalBikes = bikes.length
const totalParts = allParts.length
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500 mb-2">
Fahrräder
</h3>
<p className="text-3xl font-bold text-gray-900">{totalBikes}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500 mb-2">
Verschleißteile
</h3>
<p className="text-3xl font-bold text-gray-900">{totalParts}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500 mb-2">
Gesamtkosten
</h3>
<p className="text-3xl font-bold text-gray-900">
{formatCurrency(totalCosts)}
</p>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500 mb-2">
Ø Lebensdauer
</h3>
<p className="text-3xl font-bold text-gray-900">
{avgLifespan
? `${Math.round(avgLifespan)} km`
: 'N/A'}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,321 @@
'use client'
import { useState } from 'react'
import { wearPartSchema, WearPartType, WearPartStatus } from '@/lib/validations'
interface WearPartFormProps {
bikeId: string
onSuccess: () => void
onCancel: () => void
}
const wearPartTypes = [
'CHAIN',
'BRAKE_PADS',
'TIRE',
'CASSETTE',
'CHAINRING',
'DERAILLEUR',
'BRAKE_CABLE',
'SHIFT_CABLE',
'BRAKE_ROTOR',
'PEDAL',
'CRANKSET',
'BOTTOM_BRACKET',
'HEADSET',
'WHEEL',
'HUB',
'SPOKE',
'OTHER',
]
const wearPartStatuses = ['ACTIVE', 'NEEDS_SERVICE', 'REPLACED', 'INACTIVE']
export default function WearPartForm({
bikeId,
onSuccess,
onCancel,
}: WearPartFormProps) {
const [formData, setFormData] = useState({
type: 'CHAIN',
brand: '',
model: '',
installDate: new Date().toISOString().split('T')[0],
installMileage: 0,
serviceInterval: 1000,
status: 'ACTIVE',
cost: '',
notes: '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors({})
setIsSubmitting(true)
try {
const validatedData = wearPartSchema.parse({
bikeId,
type: formData.type,
brand: formData.brand || undefined,
model: formData.model || undefined,
installDate: formData.installDate,
installMileage: Number(formData.installMileage),
serviceInterval: Number(formData.serviceInterval),
status: formData.status,
cost: formData.cost ? Number(formData.cost) : undefined,
notes: formData.notes || undefined,
})
const response = await fetch(`/api/bikes/${bikeId}/parts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(validatedData),
})
if (response.ok) {
onSuccess()
setFormData({
type: 'CHAIN',
brand: '',
model: '',
installDate: new Date().toISOString().split('T')[0],
installMileage: 0,
serviceInterval: 1000,
status: 'ACTIVE',
cost: '',
notes: '',
})
} else {
const errorData = await response.json()
if (errorData.details) {
const zodErrors: Record<string, string> = {}
errorData.details.errors?.forEach((err: any) => {
if (err.path) {
zodErrors[err.path[0]] = err.message
}
})
setErrors(zodErrors)
} else {
alert('Fehler beim Erstellen des Verschleißteils')
}
}
} catch (error: any) {
if (error.errors) {
const zodErrors: Record<string, string> = {}
error.errors.forEach((err: any) => {
if (err.path) {
zodErrors[err.path[0]] = err.message
}
})
setErrors(zodErrors)
} else {
console.error('Error creating part:', error)
alert('Fehler beim Erstellen des Verschleißteils')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-semibold mb-4">
Neues Verschleißteil hinzufügen
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Typ *
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: 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
>
{wearPartTypes.map((type) => (
<option key={type} value={type}>
{type.replace(/_/g, ' ')}
</option>
))}
</select>
{errors.type && (
<p className="text-red-600 text-sm mt-1">{errors.type}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Marke
</label>
<input
type="text"
value={formData.brand}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Modell
</label>
<input
type="text"
value={formData.model}
onChange={(e) =>
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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Installationsdatum *
</label>
<input
type="date"
value={formData.installDate}
onChange={(e) =>
setFormData({ ...formData, installDate: 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.installDate && (
<p className="text-red-600 text-sm mt-1">{errors.installDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Installations-KM *
</label>
<input
type="number"
value={formData.installMileage}
onChange={(e) =>
setFormData({
...formData,
installMileage: Number(e.target.value),
})
}
min="0"
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.installMileage && (
<p className="text-red-600 text-sm mt-1">
{errors.installMileage}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Service-Intervall (km) *
</label>
<input
type="number"
value={formData.serviceInterval}
onChange={(e) =>
setFormData({
...formData,
serviceInterval: Number(e.target.value),
})
}
min="1"
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.serviceInterval && (
<p className="text-red-600 text-sm mt-1">
{errors.serviceInterval}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: 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"
>
{wearPartStatuses.map((status) => (
<option key={status} value={status}>
{status.replace(/_/g, ' ')}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kosten ()
</label>
<input
type="number"
step="0.01"
value={formData.cost}
onChange={(e) =>
setFormData({ ...formData, cost: e.target.value })
}
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen
</label>
<textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Erstellen...' : 'Erstellen'}
</button>
<button
type="button"
onClick={onCancel}
className="px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
Abbrechen
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,193 @@
'use client'
import { WearPart } from '@prisma/client'
import { MaintenanceHistory } from '@prisma/client'
import { calculateServiceStatus } from '@/lib/utils'
import { formatDate, formatCurrency } from '@/lib/utils'
import AlertBadge from './AlertBadge'
import MaintenanceTimeline from './MaintenanceTimeline'
import { useState } from 'react'
interface WearPartListProps {
bikeId: string
parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[]
onUpdate: () => void
}
export default function WearPartList({
bikeId,
parts,
onUpdate,
}: WearPartListProps) {
const [expandedPart, setExpandedPart] = useState<string | null>(null)
const handleDelete = async (partId: string) => {
if (!confirm('Möchten Sie dieses Verschleißteil wirklich löschen?')) {
return
}
try {
const response = await fetch(`/api/parts/${partId}`, {
method: 'DELETE',
})
if (response.ok) {
onUpdate()
} else {
alert('Fehler beim Löschen des Verschleißteils')
}
} catch (error) {
console.error('Error deleting part:', error)
alert('Fehler beim Löschen des Verschleißteils')
}
}
if (parts.length === 0) {
return (
<div className="bg-white rounded-lg shadow-md p-8 text-center text-gray-500">
<p>Noch keine Verschleißteile erfasst.</p>
<p className="mt-2">Klicken Sie auf &quot;Neues Verschleißteil&quot; um zu beginnen.</p>
</div>
)
}
return (
<div className="space-y-4">
{parts.map((part) => {
const serviceStatus = calculateServiceStatus(part)
const isExpanded = expandedPart === part.id
return (
<div
key={part.id}
className="bg-white rounded-lg shadow-md p-6"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-900">
{part.type}
</h3>
{serviceStatus.status !== 'OK' && (
<AlertBadge count={1} />
)}
<span
className={`px-2 py-1 rounded text-xs font-medium ${
part.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: part.status === 'NEEDS_SERVICE'
? 'bg-orange-100 text-orange-800'
: part.status === 'REPLACED'
? 'bg-gray-100 text-gray-800'
: 'bg-red-100 text-red-800'
}`}
>
{part.status}
</span>
</div>
{part.brand && part.model && (
<p className="text-gray-600 mb-2">
{part.brand} {part.model}
</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mt-4">
<div>
<span className="text-gray-500">Installiert:</span>
<p className="font-medium">{formatDate(part.installDate)}</p>
</div>
<div>
<span className="text-gray-500">Installations-KM:</span>
<p className="font-medium">{part.installMileage} km</p>
</div>
<div>
<span className="text-gray-500">Service-Intervall:</span>
<p className="font-medium">{part.serviceInterval} km</p>
</div>
{part.cost && (
<div>
<span className="text-gray-500">Kosten:</span>
<p className="font-medium">{formatCurrency(part.cost)}</p>
</div>
)}
</div>
<div className="mt-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-gray-500">Status:</span>
<span
className={`text-sm font-medium ${
serviceStatus.status === 'OK'
? 'text-green-600'
: serviceStatus.status === 'WARNING'
? 'text-orange-600'
: 'text-red-600'
}`}
>
{serviceStatus.status === 'OK'
? 'OK'
: serviceStatus.status === 'WARNING'
? 'Warnung'
: 'Kritisch'}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
serviceStatus.status === 'OK'
? 'bg-green-500'
: serviceStatus.status === 'WARNING'
? 'bg-orange-500'
: 'bg-red-500'
}`}
style={{ width: `${serviceStatus.percentageUsed}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{serviceStatus.remainingKm.toFixed(0)} km bis zur Wartung
</p>
</div>
{part.notes && (
<div className="mt-4">
<span className="text-sm text-gray-500">Notizen:</span>
<p className="text-sm mt-1">{part.notes}</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 ml-4">
<button
onClick={() =>
setExpandedPart(isExpanded ? null : part.id)
}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
>
{isExpanded ? 'Weniger' : 'Historie'}
</button>
<button
onClick={() => handleDelete(part.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
Löschen
</button>
</div>
</div>
{isExpanded && (
<div className="mt-6 pt-6 border-t">
<MaintenanceTimeline
partId={part.id}
history={part.maintenanceHistory}
onUpdate={onUpdate}
/>
</div>
)}
</div>
)
})}
</div>
)
}