Feature: Kilometerstand-Feld für Fahrräder und verbesserte Warnungen

- currentMileage Feld zu Bike-Modell hinzugefügt
- calculateServiceStatus verwendet jetzt aktuellen Kilometerstand des Fahrrads
- Warnungen für überfällige Wartungen (OVERDUE Status)
- Rote Warnung bei überfälligen Teilen
- Kilometerstand wird in BikeDetail und BikeCard angezeigt
- AlertBadge unterstützt jetzt critical Variante
- Verbesserte Statusanzeige mit Überfällig-Hinweis
This commit is contained in:
Denis Urs Rudolph
2025-12-05 22:36:58 +01:00
parent 81edc206e0
commit d37676f3c0
10 changed files with 134 additions and 44 deletions

View File

@@ -57,6 +57,7 @@ export async function PUT(
brand: validatedData.brand,
model: validatedData.model,
purchaseDate: validatedData.purchaseDate || null,
currentMileage: validatedData.currentMileage ?? 0,
notes: validatedData.notes,
},
})

View File

@@ -42,6 +42,7 @@ export async function POST(request: NextRequest) {
brand: validatedData.brand,
model: validatedData.model,
purchaseDate: validatedData.purchaseDate || null,
currentMileage: validatedData.currentMileage ?? 0,
notes: validatedData.notes,
},
})

View File

@@ -1,12 +1,21 @@
interface AlertBadgeProps {
count: number
variant?: 'warning' | 'critical'
}
export default function AlertBadge({ count }: AlertBadgeProps) {
export default function AlertBadge({
count,
variant = 'warning',
}: AlertBadgeProps) {
if (count === 0) return null
const bgColor = variant === 'critical' ? 'bg-red-100' : 'bg-orange-100'
const textColor = variant === 'critical' ? 'text-red-800' : 'text-orange-800'
return (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${bgColor} ${textColor}`}
>
{count}
</span>
)

View File

@@ -40,8 +40,12 @@ export default function BikeCard({ bike, onDelete }: BikeCardProps) {
const activeParts = bike.wearParts.filter((p) => p.status === 'ACTIVE')
const needsServiceParts = activeParts.filter((part) => {
const serviceStatus = calculateServiceStatus(part)
return serviceStatus.status !== 'OK'
const serviceStatus = calculateServiceStatus(part, bike.currentMileage)
return serviceStatus.status !== 'OK' || serviceStatus.isOverdue
})
const overdueParts = activeParts.filter((part) => {
const serviceStatus = calculateServiceStatus(part, bike.currentMileage)
return serviceStatus.isOverdue
})
return (
@@ -56,7 +60,10 @@ export default function BikeCard({ bike, onDelete }: BikeCardProps) {
)}
</div>
{needsServiceParts.length > 0 && (
<AlertBadge count={needsServiceParts.length} />
<AlertBadge
count={needsServiceParts.length}
variant={overdueParts.length > 0 ? 'critical' : 'warning'}
/>
)}
</div>
@@ -64,7 +71,15 @@ export default function BikeCard({ bike, onDelete }: BikeCardProps) {
<p className="text-sm text-gray-500">
{activeParts.length} aktive Verschleißteile
</p>
{needsServiceParts.length > 0 && (
<p className="text-sm text-gray-500">
{bike.currentMileage.toLocaleString('de-DE')} km
</p>
{overdueParts.length > 0 && (
<p className="text-sm text-red-600 font-medium mt-1">
{overdueParts.length} überfällig!
</p>
)}
{needsServiceParts.length > 0 && overdueParts.length === 0 && (
<p className="text-sm text-orange-600 font-medium mt-1">
{needsServiceParts.length} benötigen Wartung
</p>

View File

@@ -25,8 +25,16 @@ export default function BikeDetail({ bike, onUpdate }: BikeDetailProps) {
</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">
<div className="flex justify-between items-start mb-4">
<h1 className="text-3xl font-bold text-gray-900">{bike.name}</h1>
<div className="text-right">
<p className="text-sm text-gray-500">Aktueller Kilometerstand</p>
<p className="text-2xl font-bold text-gray-900">
{bike.currentMileage.toLocaleString('de-DE')} km
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
{bike.brand && (
<div>
<span className="text-gray-500">Marke:</span>
@@ -81,7 +89,12 @@ export default function BikeDetail({ bike, onUpdate }: BikeDetailProps) {
</div>
)}
<WearPartList bikeId={bike.id} parts={bike.wearParts} onUpdate={onUpdate} />
<WearPartList
bikeId={bike.id}
parts={bike.wearParts}
bikeCurrentMileage={bike.currentMileage}
onUpdate={onUpdate}
/>
</div>
</main>
)

View File

@@ -14,6 +14,7 @@ export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) {
brand: '',
model: '',
purchaseDate: '',
currentMileage: 0,
notes: '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
@@ -45,6 +46,7 @@ export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) {
brand: '',
model: '',
purchaseDate: '',
currentMileage: 0,
notes: '',
})
} else {
@@ -131,18 +133,38 @@ export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) {
</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 text-black"
/>
<div className="grid grid-cols-2 gap-4">
<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 text-black"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aktueller Kilometerstand (km)
</label>
<input
type="number"
value={formData.currentMileage}
onChange={(e) =>
setFormData({
...formData,
currentMileage: Number(e.target.value) || 0,
})
}
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 text-black"
/>
</div>
</div>
<div>

View File

@@ -11,12 +11,14 @@ import { useState } from 'react'
interface WearPartListProps {
bikeId: string
parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[]
bikeCurrentMileage?: number
onUpdate: () => void
}
export default function WearPartList({
bikeId,
parts,
bikeCurrentMileage,
onUpdate,
}: WearPartListProps) {
const [expandedPart, setExpandedPart] = useState<string | null>(null)
@@ -54,7 +56,7 @@ export default function WearPartList({
return (
<div className="space-y-4">
{parts.map((part) => {
const serviceStatus = calculateServiceStatus(part)
const serviceStatus = calculateServiceStatus(part, bikeCurrentMileage)
const isExpanded = expandedPart === part.id
return (
@@ -68,8 +70,11 @@ export default function WearPartList({
<h3 className="text-xl font-semibold text-gray-900">
{part.type}
</h3>
{serviceStatus.status !== 'OK' && (
<AlertBadge count={1} />
{(serviceStatus.status !== 'OK' || serviceStatus.isOverdue) && (
<AlertBadge
count={1}
variant={serviceStatus.status === 'OVERDUE' ? 'critical' : 'warning'}
/>
)}
<span
className={`px-2 py-1 rounded text-xs font-medium ${
@@ -122,16 +127,25 @@ export default function WearPartList({
? 'text-green-600'
: serviceStatus.status === 'WARNING'
? 'text-orange-600'
: 'text-red-600'
: serviceStatus.status === 'CRITICAL'
? 'text-red-600'
: 'text-red-800 font-bold'
}`}
>
{serviceStatus.status === 'OK'
? 'OK'
: serviceStatus.status === 'WARNING'
? 'Warnung'
: 'Kritisch'}
: serviceStatus.status === 'CRITICAL'
? 'Kritisch'
: 'Überfällig!'}
</span>
</div>
{serviceStatus.isOverdue && (
<div className="mb-2 p-2 bg-red-100 border border-red-300 rounded text-sm text-red-800">
Wartung ist um {Math.abs(serviceStatus.remainingKm).toFixed(0)} km überfällig!
</div>
)}
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
@@ -139,13 +153,19 @@ export default function WearPartList({
? 'bg-green-500'
: serviceStatus.status === 'WARNING'
? 'bg-orange-500'
: 'bg-red-500'
: serviceStatus.status === 'CRITICAL'
? 'bg-red-500'
: 'bg-red-700'
}`}
style={{ width: `${serviceStatus.percentageUsed}%` }}
style={{
width: `${Math.min(100, Math.max(0, serviceStatus.percentageUsed))}%`,
}}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{serviceStatus.remainingKm.toFixed(0)} km bis zur Wartung
{serviceStatus.isOverdue
? `${Math.abs(serviceStatus.remainingKm).toFixed(0)} km überfällig`
: `${serviceStatus.remainingKm.toFixed(0)} km bis zur Wartung`}
</p>
</div>

View File

@@ -1,20 +1,26 @@
import { WearPart, MaintenanceHistory } from '@prisma/client'
export function calculateServiceStatus(
part: WearPart & { maintenanceHistory: MaintenanceHistory[] }
part: WearPart & { maintenanceHistory: MaintenanceHistory[] },
bikeCurrentMileage?: number
): {
status: 'OK' | 'WARNING' | 'CRITICAL'
status: 'OK' | 'WARNING' | 'CRITICAL' | 'OVERDUE'
remainingKm: number
percentageUsed: number
isOverdue: boolean
} {
// Use bike's current mileage if provided, otherwise use latest maintenance mileage
const latestMaintenance = part.maintenanceHistory[0]
const currentMileage = latestMaintenance?.mileage ?? part.installMileage
const currentMileage = bikeCurrentMileage ?? (latestMaintenance?.mileage ?? part.installMileage)
const kmSinceInstall = currentMileage - part.installMileage
const remainingKm = part.serviceInterval - kmSinceInstall
const percentageUsed = (kmSinceInstall / part.serviceInterval) * 100
const isOverdue = remainingKm < 0
let status: 'OK' | 'WARNING' | 'CRITICAL' = 'OK'
if (percentageUsed >= 90) {
let status: 'OK' | 'WARNING' | 'CRITICAL' | 'OVERDUE' = 'OK'
if (isOverdue) {
status = 'OVERDUE'
} else if (percentageUsed >= 90) {
status = 'CRITICAL'
} else if (percentageUsed >= 75) {
status = 'WARNING'
@@ -23,7 +29,8 @@ export function calculateServiceStatus(
return {
status,
remainingKm: Math.max(0, remainingKm),
percentageUsed: Math.min(100, percentageUsed),
percentageUsed: Math.min(100, Math.max(0, percentageUsed)),
isOverdue,
}
}

View File

@@ -66,6 +66,7 @@ export const bikeSchema = z.object({
.refine((val) => val === undefined || val instanceof Date, {
message: 'Ungültiges Datumsformat',
}),
currentMileage: z.number().int().min(0).optional().default(0),
notes: z.string().optional().transform((val) => val?.trim() || undefined),
})

View File

@@ -11,15 +11,16 @@ datasource 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
id String @id @default(cuid())
name String
brand String?
model String?
purchaseDate DateTime?
currentMileage Int @default(0)
notes String?
wearParts WearPart[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model WearPart {