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:
@@ -57,6 +57,7 @@ export async function PUT(
|
|||||||
brand: validatedData.brand,
|
brand: validatedData.brand,
|
||||||
model: validatedData.model,
|
model: validatedData.model,
|
||||||
purchaseDate: validatedData.purchaseDate || null,
|
purchaseDate: validatedData.purchaseDate || null,
|
||||||
|
currentMileage: validatedData.currentMileage ?? 0,
|
||||||
notes: validatedData.notes,
|
notes: validatedData.notes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function POST(request: NextRequest) {
|
|||||||
brand: validatedData.brand,
|
brand: validatedData.brand,
|
||||||
model: validatedData.model,
|
model: validatedData.model,
|
||||||
purchaseDate: validatedData.purchaseDate || null,
|
purchaseDate: validatedData.purchaseDate || null,
|
||||||
|
currentMileage: validatedData.currentMileage ?? 0,
|
||||||
notes: validatedData.notes,
|
notes: validatedData.notes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
interface AlertBadgeProps {
|
interface AlertBadgeProps {
|
||||||
count: number
|
count: number
|
||||||
|
variant?: 'warning' | 'critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlertBadge({ count }: AlertBadgeProps) {
|
export default function AlertBadge({
|
||||||
|
count,
|
||||||
|
variant = 'warning',
|
||||||
|
}: AlertBadgeProps) {
|
||||||
if (count === 0) return null
|
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 (
|
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}
|
⚠️ {count}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ export default function BikeCard({ bike, onDelete }: BikeCardProps) {
|
|||||||
|
|
||||||
const activeParts = bike.wearParts.filter((p) => p.status === 'ACTIVE')
|
const activeParts = bike.wearParts.filter((p) => p.status === 'ACTIVE')
|
||||||
const needsServiceParts = activeParts.filter((part) => {
|
const needsServiceParts = activeParts.filter((part) => {
|
||||||
const serviceStatus = calculateServiceStatus(part)
|
const serviceStatus = calculateServiceStatus(part, bike.currentMileage)
|
||||||
return serviceStatus.status !== 'OK'
|
return serviceStatus.status !== 'OK' || serviceStatus.isOverdue
|
||||||
|
})
|
||||||
|
const overdueParts = activeParts.filter((part) => {
|
||||||
|
const serviceStatus = calculateServiceStatus(part, bike.currentMileage)
|
||||||
|
return serviceStatus.isOverdue
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +60,10 @@ export default function BikeCard({ bike, onDelete }: BikeCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{needsServiceParts.length > 0 && (
|
{needsServiceParts.length > 0 && (
|
||||||
<AlertBadge count={needsServiceParts.length} />
|
<AlertBadge
|
||||||
|
count={needsServiceParts.length}
|
||||||
|
variant={overdueParts.length > 0 ? 'critical' : 'warning'}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,7 +71,15 @@ export default function BikeCard({ bike, onDelete }: BikeCardProps) {
|
|||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{activeParts.length} aktive Verschleißteile
|
{activeParts.length} aktive Verschleißteile
|
||||||
</p>
|
</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">
|
<p className="text-sm text-orange-600 font-medium mt-1">
|
||||||
{needsServiceParts.length} benötigen Wartung
|
{needsServiceParts.length} benötigen Wartung
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ export default function BikeDetail({ bike, onUpdate }: BikeDetailProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<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="flex justify-between items-start mb-4">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<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 && (
|
{bike.brand && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Marke:</span>
|
<span className="text-gray-500">Marke:</span>
|
||||||
@@ -81,7 +89,12 @@ export default function BikeDetail({ bike, onUpdate }: BikeDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WearPartList bikeId={bike.id} parts={bike.wearParts} onUpdate={onUpdate} />
|
<WearPartList
|
||||||
|
bikeId={bike.id}
|
||||||
|
parts={bike.wearParts}
|
||||||
|
bikeCurrentMileage={bike.currentMileage}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) {
|
|||||||
brand: '',
|
brand: '',
|
||||||
model: '',
|
model: '',
|
||||||
purchaseDate: '',
|
purchaseDate: '',
|
||||||
|
currentMileage: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
})
|
})
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
@@ -45,6 +46,7 @@ export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) {
|
|||||||
brand: '',
|
brand: '',
|
||||||
model: '',
|
model: '',
|
||||||
purchaseDate: '',
|
purchaseDate: '',
|
||||||
|
currentMileage: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -131,18 +133,38 @@ export default function BikeForm({ onSuccess, onCancel }: BikeFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div>
|
||||||
Kaufdatum
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
</label>
|
Kaufdatum
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<input
|
||||||
value={formData.purchaseDate}
|
type="date"
|
||||||
onChange={(e) =>
|
value={formData.purchaseDate}
|
||||||
setFormData({ ...formData, purchaseDate: e.target.value })
|
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"
|
}
|
||||||
/>
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { useState } from 'react'
|
|||||||
interface WearPartListProps {
|
interface WearPartListProps {
|
||||||
bikeId: string
|
bikeId: string
|
||||||
parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[]
|
parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[]
|
||||||
|
bikeCurrentMileage?: number
|
||||||
onUpdate: () => void
|
onUpdate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WearPartList({
|
export default function WearPartList({
|
||||||
bikeId,
|
bikeId,
|
||||||
parts,
|
parts,
|
||||||
|
bikeCurrentMileage,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: WearPartListProps) {
|
}: WearPartListProps) {
|
||||||
const [expandedPart, setExpandedPart] = useState<string | null>(null)
|
const [expandedPart, setExpandedPart] = useState<string | null>(null)
|
||||||
@@ -54,7 +56,7 @@ export default function WearPartList({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{parts.map((part) => {
|
{parts.map((part) => {
|
||||||
const serviceStatus = calculateServiceStatus(part)
|
const serviceStatus = calculateServiceStatus(part, bikeCurrentMileage)
|
||||||
const isExpanded = expandedPart === part.id
|
const isExpanded = expandedPart === part.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,8 +70,11 @@ export default function WearPartList({
|
|||||||
<h3 className="text-xl font-semibold text-gray-900">
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
{part.type}
|
{part.type}
|
||||||
</h3>
|
</h3>
|
||||||
{serviceStatus.status !== 'OK' && (
|
{(serviceStatus.status !== 'OK' || serviceStatus.isOverdue) && (
|
||||||
<AlertBadge count={1} />
|
<AlertBadge
|
||||||
|
count={1}
|
||||||
|
variant={serviceStatus.status === 'OVERDUE' ? 'critical' : 'warning'}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
@@ -122,16 +127,25 @@ export default function WearPartList({
|
|||||||
? 'text-green-600'
|
? 'text-green-600'
|
||||||
: serviceStatus.status === 'WARNING'
|
: serviceStatus.status === 'WARNING'
|
||||||
? 'text-orange-600'
|
? 'text-orange-600'
|
||||||
: 'text-red-600'
|
: serviceStatus.status === 'CRITICAL'
|
||||||
|
? 'text-red-600'
|
||||||
|
: 'text-red-800 font-bold'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{serviceStatus.status === 'OK'
|
{serviceStatus.status === 'OK'
|
||||||
? 'OK'
|
? 'OK'
|
||||||
: serviceStatus.status === 'WARNING'
|
: serviceStatus.status === 'WARNING'
|
||||||
? 'Warnung'
|
? 'Warnung'
|
||||||
: 'Kritisch'}
|
: serviceStatus.status === 'CRITICAL'
|
||||||
|
? 'Kritisch'
|
||||||
|
: 'Überfällig!'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 rounded-full ${
|
className={`h-2 rounded-full ${
|
||||||
@@ -139,13 +153,19 @@ export default function WearPartList({
|
|||||||
? 'bg-green-500'
|
? 'bg-green-500'
|
||||||
: serviceStatus.status === 'WARNING'
|
: serviceStatus.status === 'WARNING'
|
||||||
? 'bg-orange-500'
|
? '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>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
19
lib/utils.ts
19
lib/utils.ts
@@ -1,20 +1,26 @@
|
|||||||
import { WearPart, MaintenanceHistory } from '@prisma/client'
|
import { WearPart, MaintenanceHistory } from '@prisma/client'
|
||||||
|
|
||||||
export function calculateServiceStatus(
|
export function calculateServiceStatus(
|
||||||
part: WearPart & { maintenanceHistory: MaintenanceHistory[] }
|
part: WearPart & { maintenanceHistory: MaintenanceHistory[] },
|
||||||
|
bikeCurrentMileage?: number
|
||||||
): {
|
): {
|
||||||
status: 'OK' | 'WARNING' | 'CRITICAL'
|
status: 'OK' | 'WARNING' | 'CRITICAL' | 'OVERDUE'
|
||||||
remainingKm: number
|
remainingKm: number
|
||||||
percentageUsed: number
|
percentageUsed: number
|
||||||
|
isOverdue: boolean
|
||||||
} {
|
} {
|
||||||
|
// Use bike's current mileage if provided, otherwise use latest maintenance mileage
|
||||||
const latestMaintenance = part.maintenanceHistory[0]
|
const latestMaintenance = part.maintenanceHistory[0]
|
||||||
const currentMileage = latestMaintenance?.mileage ?? part.installMileage
|
const currentMileage = bikeCurrentMileage ?? (latestMaintenance?.mileage ?? part.installMileage)
|
||||||
const kmSinceInstall = currentMileage - part.installMileage
|
const kmSinceInstall = currentMileage - part.installMileage
|
||||||
const remainingKm = part.serviceInterval - kmSinceInstall
|
const remainingKm = part.serviceInterval - kmSinceInstall
|
||||||
const percentageUsed = (kmSinceInstall / part.serviceInterval) * 100
|
const percentageUsed = (kmSinceInstall / part.serviceInterval) * 100
|
||||||
|
const isOverdue = remainingKm < 0
|
||||||
|
|
||||||
let status: 'OK' | 'WARNING' | 'CRITICAL' = 'OK'
|
let status: 'OK' | 'WARNING' | 'CRITICAL' | 'OVERDUE' = 'OK'
|
||||||
if (percentageUsed >= 90) {
|
if (isOverdue) {
|
||||||
|
status = 'OVERDUE'
|
||||||
|
} else if (percentageUsed >= 90) {
|
||||||
status = 'CRITICAL'
|
status = 'CRITICAL'
|
||||||
} else if (percentageUsed >= 75) {
|
} else if (percentageUsed >= 75) {
|
||||||
status = 'WARNING'
|
status = 'WARNING'
|
||||||
@@ -23,7 +29,8 @@ export function calculateServiceStatus(
|
|||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
remainingKm: Math.max(0, remainingKm),
|
remainingKm: Math.max(0, remainingKm),
|
||||||
percentageUsed: Math.min(100, percentageUsed),
|
percentageUsed: Math.min(100, Math.max(0, percentageUsed)),
|
||||||
|
isOverdue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const bikeSchema = z.object({
|
|||||||
.refine((val) => val === undefined || val instanceof Date, {
|
.refine((val) => val === undefined || val instanceof Date, {
|
||||||
message: 'Ungültiges Datumsformat',
|
message: 'Ungültiges Datumsformat',
|
||||||
}),
|
}),
|
||||||
|
currentMileage: z.number().int().min(0).optional().default(0),
|
||||||
notes: z.string().optional().transform((val) => val?.trim() || undefined),
|
notes: z.string().optional().transform((val) => val?.trim() || undefined),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Bike {
|
model Bike {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
brand String?
|
brand String?
|
||||||
model String?
|
model String?
|
||||||
purchaseDate DateTime?
|
purchaseDate DateTime?
|
||||||
notes String?
|
currentMileage Int @default(0)
|
||||||
wearParts WearPart[]
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
wearParts WearPart[]
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model WearPart {
|
model WearPart {
|
||||||
|
|||||||
Reference in New Issue
Block a user