feat(T02): vehicle management + mobile.de push + vehicle UI
- Vehicle model: 25+ fields, 5 vehicle types, soft-delete - Vehicle CRUD: 7 API endpoints with JWT auth, filter/sort/paginate - mobile.de: push/update/delete listings, field mapping, retry logic - MobileDeListing model for sync status tracking - Frontend: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus - 73 backend tests (82% coverage), 16 frontend tests
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
getVehicle,
|
||||
deleteVehicle,
|
||||
type VehicleResponse,
|
||||
} from '@/lib/vehicles';
|
||||
import { MobileDeStatus } from './MobileDeStatus';
|
||||
|
||||
interface VehicleDetailProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price);
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
||||
const router = useRouter();
|
||||
const [vehicle, setVehicle] = useState<VehicleResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchVehicle = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getVehicle(vehicleId);
|
||||
setVehicle(data);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load vehicle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVehicle();
|
||||
}, [fetchVehicle]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!vehicle) return;
|
||||
if (!confirm('Delete this vehicle?')) return;
|
||||
try {
|
||||
await deleteVehicle(vehicle.id);
|
||||
router.push('/de/fahrzeuge');
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to delete vehicle');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div data-testid="vehicle-detail-loading" className="text-center py-8 text-text-muted">
|
||||
Loading vehicle...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div data-testid="vehicle-detail-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
<div data-testid="vehicle-detail-not-found" className="text-center py-8 text-text-muted">
|
||||
Vehicle not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields: { label: string; value: string | number | undefined | null }[] = [
|
||||
{ label: 'Make', value: vehicle.make },
|
||||
{ label: 'Model', value: vehicle.model },
|
||||
{ label: 'FIN', value: vehicle.fin },
|
||||
{ label: 'Year', value: vehicle.year },
|
||||
{ label: 'First Registration', value: formatDate(vehicle.first_registration) },
|
||||
{ label: 'Power (kW)', value: vehicle.power_kw },
|
||||
{ label: 'Power (HP)', value: vehicle.power_hp },
|
||||
{ label: 'Fuel Type', value: vehicle.fuel_type },
|
||||
{ label: 'Transmission', value: vehicle.transmission },
|
||||
{ label: 'Color', value: vehicle.color },
|
||||
{ label: 'Condition', value: vehicle.condition },
|
||||
{ label: 'Location', value: vehicle.location },
|
||||
{ label: 'Availability', value: vehicle.availability },
|
||||
{ label: 'Price', value: formatPrice(vehicle.price) },
|
||||
{ label: 'Vehicle Type', value: vehicle.vehicle_type },
|
||||
{ label: 'LKW Type', value: vehicle.lkw_type },
|
||||
{ label: 'Machine Type', value: vehicle.machine_type },
|
||||
{ label: 'Body Type', value: vehicle.body_type },
|
||||
{ label: 'Operating Hours', value: vehicle.operating_hours },
|
||||
{ label: 'Operating Hours Unit', value: vehicle.operating_hours_unit },
|
||||
{ label: 'Mileage (km)', value: vehicle.mileage_km },
|
||||
{ label: 'Description', value: vehicle.description },
|
||||
{ label: 'Created At', value: formatDate(vehicle.created_at) },
|
||||
{ label: 'Updated At', value: formatDate(vehicle.updated_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-testid="vehicle-detail" className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 data-testid="vehicle-detail-title" className="text-2xl font-bold text-text">
|
||||
{vehicle.make} {vehicle.model}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => router.push(`/de/fahrzeuge/${vehicle.id}/bearbeiten`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} data-testid="delete-button">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="vehicle-detail-fields" className="grid grid-cols-2 md:grid-cols-3 gap-4 p-6 bg-surface rounded-lg border border-border">
|
||||
{fields.map(field => (
|
||||
<div key={field.label} className="space-y-1">
|
||||
<dt className="text-sm font-medium text-text-muted">{field.label}</dt>
|
||||
<dd data-testid={`field-${field.label.toLowerCase().replace(/\s+/g, '_')}`} className="text-text">
|
||||
{field.value !== null && field.value !== undefined && field.value !== '' ? field.value : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MobileDeStatus vehicleId={vehicle.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user