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:
2026-07-14 12:03:19 +02:00
parent d89304845a
commit 74b5e6afb8
24 changed files with 3847 additions and 97 deletions
@@ -0,0 +1,14 @@
import { VehicleDetail } from '@/components/vehicles/VehicleDetail';
export default async function FahrzeugDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
return (
<div className="max-w-4xl mx-auto p-6">
<VehicleDetail vehicleId={id} />
</div>
);
}
@@ -0,0 +1,10 @@
import { VehicleForm } from '@/components/vehicles/VehicleForm';
export default function NeuesFahrzeugPage() {
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold text-text mb-6">Neues Fahrzeug</h1>
<VehicleForm mode="create" />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { VehicleList } from '@/components/vehicles/VehicleList';
export default function FahrzeugePage() {
return <VehicleList />;
}
@@ -0,0 +1,131 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/Button';
import {
getMobileDeStatus,
pushToMobileDe,
type MobileDeStatusResponse,
} from '@/lib/vehicles';
interface MobileDeStatusProps {
vehicleId: string;
}
const STATUS_STYLES: Record<string, string> = {
synced: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
fehler: 'bg-red-100 text-red-800',
deleted: 'bg-gray-100 text-gray-800',
};
const STATUS_LABELS: Record<string, string> = {
synced: 'Synced',
pending: 'Pending',
fehler: 'Error',
deleted: 'Deleted',
};
export function MobileDeStatus({ vehicleId }: MobileDeStatusProps) {
const [status, setStatus] = useState<MobileDeStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [pushing, setPushing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStatus = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getMobileDeStatus(vehicleId);
setStatus(data);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load mobile.de status');
} finally {
setLoading(false);
}
}, [vehicleId]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const handlePush = async () => {
setPushing(true);
setError(null);
try {
await pushToMobileDe(vehicleId);
await fetchStatus();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to push to mobile.de');
} finally {
setPushing(false);
}
};
const syncStatus = status?.sync_status || 'pending';
const statusStyle = STATUS_STYLES[syncStatus] || 'bg-gray-100 text-gray-800';
const statusLabel = STATUS_LABELS[syncStatus] || syncStatus;
return (
<div data-testid="mobile-de-status" className="p-6 bg-surface rounded-lg border border-border space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">mobile.de Status</h2>
<Button
variant="primary"
loading={pushing}
onClick={handlePush}
data-testid="mobile-de-push-button"
>
Push to mobile.de
</Button>
</div>
{loading ? (
<div data-testid="mobile-de-loading" className="text-text-muted">
Loading status...
</div>
) : error ? (
<div data-testid="mobile-de-error" className="text-error">
{error}
</div>
) : status ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-text-muted">Sync Status:</span>
<span
data-testid="mobile-de-sync-status"
className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyle}`}
>
{statusLabel}
</span>
</div>
<div data-testid="mobile-de-synced" className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-text-muted">Synced</dt>
<dd className="text-text">{status.synced ? 'Yes' : 'No'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-muted">Ad ID</dt>
<dd className="text-text">{status.ad_id || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-muted">Synced At</dt>
<dd className="text-text">
{status.synced_at ? new Date(status.synced_at).toLocaleString('de-DE') : '—'}
</dd>
</div>
</div>
{status.error_log && (
<div data-testid="mobile-de-error-log" className="p-3 bg-error/10 text-error rounded-lg text-sm">
<strong>Error:</strong> {status.error_log}
</div>
)}
</div>
) : null}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,340 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
createVehicle,
updateVehicle,
type VehicleResponse,
type VehicleCreateData,
} from '@/lib/vehicles';
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
const CONDITIONS = ['new', 'used'];
const AVAILABILITY = ['available', 'reserved', 'sold'];
const FUEL_TYPES = ['Diesel', 'Petrol', 'Electric', 'Hybrid', 'Gas'];
const TRANSMISSIONS = ['Manual', 'Automatic', 'Semi-Automatic'];
const HOURS_UNITS = ['h', 'min'];
interface VehicleFormProps {
vehicle?: VehicleResponse;
mode?: 'create' | 'edit';
}
interface FormErrors {
[key: string]: string | undefined;
}
export function VehicleForm({ vehicle, mode = 'create' }: VehicleFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [formData, setFormData] = useState<VehicleCreateData>({
make: vehicle?.make || '',
model: vehicle?.model || '',
fin: vehicle?.fin || '',
year: vehicle?.year,
first_registration: vehicle?.first_registration,
power_kw: vehicle?.power_kw,
fuel_type: vehicle?.fuel_type,
transmission: vehicle?.transmission,
color: vehicle?.color,
condition: vehicle?.condition || 'used',
location: vehicle?.location,
availability: vehicle?.availability || 'available',
price: vehicle?.price || 0,
vehicle_type: vehicle?.vehicle_type || 'lkw',
lkw_type: vehicle?.lkw_type,
machine_type: vehicle?.machine_type,
body_type: vehicle?.body_type,
operating_hours: vehicle?.operating_hours,
operating_hours_unit: vehicle?.operating_hours_unit,
mileage_km: vehicle?.mileage_km,
description: vehicle?.description,
});
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.make || formData.make.trim().length === 0) {
newErrors.make = 'Make is required';
}
if (!formData.model || formData.model.trim().length === 0) {
newErrors.model = 'Model is required';
}
if (!formData.fin) {
newErrors.fin = 'FIN is required';
} else if (formData.fin.length !== 17) {
newErrors.fin = 'FIN must be exactly 17 characters';
}
if (!formData.price || formData.price <= 0) {
newErrors.price = 'Price must be greater than 0';
}
if (!formData.vehicle_type) {
newErrors.vehicle_type = 'Vehicle type is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (field: keyof VehicleCreateData, value: string | number | undefined) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => { const next = { ...prev }; delete next[field]; return next; });
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setSubmitError(null);
try {
if (mode === 'edit' && vehicle) {
await updateVehicle(vehicle.id, formData);
router.push(`/de/fahrzeuge/${vehicle.id}`);
} else {
const created = await createVehicle(formData);
router.push(`/de/fahrzeuge/${created.id}`);
}
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setSubmitError(apiErr?.error?.message || 'Failed to save vehicle');
} finally {
setLoading(false);
}
};
const inputClass = 'w-full px-3 py-2 border rounded-lg bg-surface text-text border-border focus:outline-none focus:ring-2 focus:ring-primary';
return (
<form data-testid="vehicle-form" onSubmit={handleSubmit} className="space-y-6">
{submitError && (
<div data-testid="form-submit-error" className="p-4 bg-error/10 text-error rounded-lg">
{submitError}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<Input
label="Make *"
data-testid="input-make"
value={formData.make}
onChange={e => handleChange('make', e.target.value)}
error={errors.make}
/>
<Input
label="Model *"
data-testid="input-model"
value={formData.model}
onChange={e => handleChange('model', e.target.value)}
error={errors.model}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="FIN * (17 characters)"
data-testid="input-fin"
value={formData.fin}
maxLength={17}
onChange={e => handleChange('fin', e.target.value.toUpperCase())}
error={errors.fin}
/>
<div>
<label className="block text-sm font-medium text-text mb-1">Vehicle Type *</label>
<select
data-testid="input-vehicle_type"
className={inputClass}
value={formData.vehicle_type}
onChange={e => handleChange('vehicle_type', e.target.value)}
>
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
{errors.vehicle_type && <p className="mt-1 text-sm text-error">{errors.vehicle_type}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Price (EUR) *"
data-testid="input-price"
type="number"
step="0.01"
min="0"
value={formData.price || ''}
onChange={e => handleChange('price', parseFloat(e.target.value) || 0)}
error={errors.price}
/>
<div>
<label className="block text-sm font-medium text-text mb-1">Availability</label>
<select
data-testid="input-availability"
className={inputClass}
value={formData.availability}
onChange={e => handleChange('availability', e.target.value)}
>
{AVAILABILITY.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<Input
label="Year"
data-testid="input-year"
type="number"
min="1900"
max="2100"
value={formData.year || ''}
onChange={e => handleChange('year', e.target.value ? parseInt(e.target.value) : undefined)}
/>
<Input
label="First Registration"
data-testid="input-first_registration"
type="date"
value={formData.first_registration || ''}
onChange={e => handleChange('first_registration', e.target.value || undefined)}
/>
<Input
label="Power (kW)"
data-testid="input-power_kw"
type="number"
min="0"
value={formData.power_kw || ''}
onChange={e => handleChange('power_kw', e.target.value ? parseInt(e.target.value) : undefined)}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text mb-1">Fuel Type</label>
<select
data-testid="input-fuel_type"
className={inputClass}
value={formData.fuel_type || ''}
onChange={e => handleChange('fuel_type', e.target.value || undefined)}
>
<option value=""></option>
{FUEL_TYPES.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Transmission</label>
<select
data-testid="input-transmission"
className={inputClass}
value={formData.transmission || ''}
onChange={e => handleChange('transmission', e.target.value || undefined)}
>
<option value=""></option>
{TRANSMISSIONS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Condition</label>
<select
data-testid="input-condition"
className={inputClass}
value={formData.condition}
onChange={e => handleChange('condition', e.target.value)}
>
{CONDITIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Color"
data-testid="input-color"
value={formData.color || ''}
onChange={e => handleChange('color', e.target.value || undefined)}
/>
<Input
label="Location"
data-testid="input-location"
value={formData.location || ''}
onChange={e => handleChange('location', e.target.value || undefined)}
/>
</div>
{formData.vehicle_type === 'lkw' && (
<Input
label="LKW Type"
data-testid="input-lkw_type"
value={formData.lkw_type || ''}
onChange={e => handleChange('lkw_type', e.target.value || undefined)}
/>
)}
{formData.vehicle_type === 'baumaschine' && (
<Input
label="Machine Type"
data-testid="input-machine_type"
value={formData.machine_type || ''}
onChange={e => handleChange('machine_type', e.target.value || undefined)}
/>
)}
<div className="grid grid-cols-2 gap-4">
<Input
label="Mileage (km)"
data-testid="input-mileage_km"
type="number"
min="0"
value={formData.mileage_km || ''}
onChange={e => handleChange('mileage_km', e.target.value ? parseInt(e.target.value) : undefined)}
/>
<div>
<label className="block text-sm font-medium text-text mb-1">Operating Hours</label>
<div className="flex gap-2">
<Input
data-testid="input-operating_hours"
type="number"
step="0.1"
min="0"
value={formData.operating_hours || ''}
onChange={e => handleChange('operating_hours', e.target.value ? parseFloat(e.target.value) : undefined)}
/>
<select
data-testid="input-operating_hours_unit"
className={inputClass}
style={{ maxWidth: '80px' }}
value={formData.operating_hours_unit || 'h'}
onChange={e => handleChange('operating_hours_unit', e.target.value || undefined)}
>
{HOURS_UNITS.map(u => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Description</label>
<textarea
data-testid="input-description"
className={inputClass}
rows={4}
value={formData.description || ''}
onChange={e => handleChange('description', e.target.value || undefined)}
/>
</div>
<div className="flex gap-3">
<Button type="submit" loading={loading} data-testid="submit-button">
{mode === 'edit' ? 'Update Vehicle' : 'Create Vehicle'}
</Button>
<Button type="button" variant="ghost" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
);
}
@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Table } from '@/components/ui/Table';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
listVehicles,
type VehicleResponse,
type VehicleListParams,
} from '@/lib/vehicles';
import type { PaginatedResponse } from '@/lib/api';
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
const SORT_OPTIONS = [
{ value: '-created_at', label: 'Newest First' },
{ value: 'created_at', label: 'Oldest First' },
{ value: 'make', label: 'Make A-Z' },
{ value: '-make', label: 'Make Z-A' },
{ value: '-price', label: 'Price High-Low' },
{ value: 'price', label: 'Price Low-High' },
];
export function VehicleList() {
const router = useRouter();
const [vehicles, setVehicles] = useState<VehicleResponse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<VehicleListParams>({
page: 1,
page_size: 20,
});
const fetchVehicles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: PaginatedResponse<VehicleResponse> = await listVehicles(filters);
setVehicles(data.items);
setTotal(data.total);
setPage(data.page);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load vehicles');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
fetchVehicles();
}, [fetchVehicles]);
const handleFilterChange = (key: keyof VehicleListParams, value: string) => {
setFilters(prev => ({
...prev,
page: 1,
[key]: value || undefined,
}));
};
const handlePageChange = (newPage: number) => {
setFilters(prev => ({ ...prev, page: newPage }));
};
const columns = [
{
key: 'make',
label: 'Make',
render: (row: VehicleResponse) => (
<button
data-testid={`vehicle-row-${row.id}`}
onClick={() => router.push(`/de/fahrzeuge/${row.id}`)}
className="text-primary hover:underline"
>
{row.make}
</button>
),
},
{ key: 'model', label: 'Model' },
{ key: 'fin', label: 'FIN' },
{ key: 'vehicle_type', label: 'Type' },
{ key: 'availability', label: 'Availability' },
{
key: 'price',
label: 'Price',
render: (row: VehicleResponse) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(row.price),
},
];
const totalPages = Math.ceil(total / pageSize);
return (
<div data-testid="vehicle-list" className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
+ Neues Fahrzeug
</Button>
</div>
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
<div className="w-48">
<label className="block text-sm font-medium text-text mb-1">Search</label>
<Input
data-testid="filter-search"
type="text"
placeholder="Make, model, FIN..."
value={filters.search || ''}
onChange={e => handleFilterChange('search', e.target.value)}
/>
</div>
<div className="w-40">
<label className="block text-sm font-medium text-text mb-1">Type</label>
<select
data-testid="filter-type"
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
value={filters.type || ''}
onChange={e => handleFilterChange('type', e.target.value)}
>
<option value="">All</option>
{VEHICLE_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="w-40">
<label className="block text-sm font-medium text-text mb-1">Availability</label>
<select
data-testid="filter-availability"
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
value={filters.availability || ''}
onChange={e => handleFilterChange('availability', e.target.value)}
>
<option value="">All</option>
{AVAILABILITY_OPTIONS.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
<div className="w-40">
<label className="block text-sm font-medium text-text mb-1">Sort</label>
<select
data-testid="filter-sort"
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
value={filters.sort || ''}
onChange={e => handleFilterChange('sort', e.target.value)}
>
<option value="">Default</option>
{SORT_OPTIONS.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{error && (
<div data-testid="vehicle-error" className="p-4 bg-error/10 text-error rounded-lg">
{error}
</div>
)}
{loading ? (
<div data-testid="vehicle-loading" className="text-center py-8 text-text-muted">
Loading vehicles...
</div>
) : (
<Table columns={columns} data={vehicles} rowKey={row => row.id} />
)}
{totalPages > 1 && (
<div data-testid="vehicle-pagination" className="flex items-center justify-between">
<span className="text-sm text-text-muted">
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<Button
variant="secondary"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Previous
</Button>
<Button
variant="secondary"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { apiFetch, type PaginatedResponse } from './api';
export interface VehicleResponse {
id: string;
make: string;
model: string;
fin: string;
year?: number;
first_registration?: string;
power_kw?: number;
power_hp?: number;
fuel_type?: string;
transmission?: string;
color?: string;
condition: string;
location?: string;
availability: string;
price: number;
vehicle_type: string;
lkw_type?: string;
machine_type?: string;
body_type?: string;
operating_hours?: number;
operating_hours_unit?: string;
mileage_km?: number;
description?: string;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
}
export interface VehicleCreateData {
make: string;
model: string;
fin: string;
year?: number;
first_registration?: string;
power_kw?: number;
fuel_type?: string;
transmission?: string;
color?: string;
condition?: string;
location?: string;
availability?: string;
price: number;
vehicle_type: string;
lkw_type?: string;
machine_type?: string;
body_type?: string;
operating_hours?: number;
operating_hours_unit?: string;
mileage_km?: number;
description?: string;
}
export interface VehicleUpdateData {
make?: string;
model?: string;
fin?: string;
year?: number;
first_registration?: string;
power_kw?: number;
fuel_type?: string;
transmission?: string;
color?: string;
condition?: string;
location?: string;
availability?: string;
price?: number;
vehicle_type?: string;
lkw_type?: string;
machine_type?: string;
body_type?: string;
operating_hours?: number;
operating_hours_unit?: string;
mileage_km?: number;
description?: string;
}
export interface VehicleListParams {
page?: number;
page_size?: number;
type?: string;
availability?: string;
min_price?: number;
max_price?: number;
search?: string;
sort?: string;
}
export interface MobileDeStatusResponse {
synced: boolean;
ad_id?: string;
synced_at?: string;
sync_status: string;
error_log?: string;
}
export interface MobileDePushResponse {
message: string;
vehicle_id: string;
listing_id?: string;
}
export async function listVehicles(params: VehicleListParams = {}): Promise<PaginatedResponse<VehicleResponse>> {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
if (params.page_size) query.set('page_size', String(params.page_size));
if (params.type) query.set('type', params.type);
if (params.availability) query.set('availability', params.availability);
if (params.min_price !== undefined) query.set('min_price', String(params.min_price));
if (params.max_price !== undefined) query.set('max_price', String(params.max_price));
if (params.search) query.set('search', params.search);
if (params.sort) query.set('sort', params.sort);
const qs = query.toString();
return apiFetch<PaginatedResponse<VehicleResponse>>(`/vehicles/${qs ? `?${qs}` : ''}`);
}
export async function getVehicle(id: string): Promise<VehicleResponse> {
return apiFetch<VehicleResponse>(`/vehicles/${id}`);
}
export async function createVehicle(data: VehicleCreateData): Promise<VehicleResponse> {
return apiFetch<VehicleResponse>('/vehicles/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateVehicle(id: string, data: VehicleUpdateData): Promise<VehicleResponse> {
return apiFetch<VehicleResponse>(`/vehicles/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteVehicle(id: string): Promise<VehicleResponse> {
return apiFetch<VehicleResponse>(`/vehicles/${id}`, { method: 'DELETE' });
}
export async function pushToMobileDe(vehicleId: string): Promise<MobileDePushResponse> {
return apiFetch<MobileDePushResponse>(`/vehicles/${vehicleId}/mobile-de/push`, {
method: 'POST',
});
}
export async function getMobileDeStatus(vehicleId: string): Promise<MobileDeStatusResponse> {
return apiFetch<MobileDeStatusResponse>(`/vehicles/${vehicleId}/mobile-de/status`);
}
+380
View File
@@ -0,0 +1,380 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ToastProvider } from '@/components/ui/Toast';
import { I18nProvider } from '@/lib/i18n';
import { VehicleList } from '@/components/vehicles/VehicleList';
import { VehicleForm } from '@/components/vehicles/VehicleForm';
import { VehicleDetail } from '@/components/vehicles/VehicleDetail';
import { MobileDeStatus } from '@/components/vehicles/MobileDeStatus';
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
refresh: vi.fn(),
}),
redirect: vi.fn(),
usePathname: () => '/de/fahrzeuge',
useSearchParams: () => new URLSearchParams(),
}));
// Mock the vehicles API module
vi.mock('@/lib/vehicles', () => ({
listVehicles: vi.fn(),
getVehicle: vi.fn(),
createVehicle: vi.fn(),
updateVehicle: vi.fn(),
deleteVehicle: vi.fn(),
pushToMobileDe: vi.fn(),
getMobileDeStatus: vi.fn(),
}));
import {
listVehicles as mockListVehicles,
getVehicle as mockGetVehicle,
createVehicle as mockCreateVehicle,
deleteVehicle as mockDeleteVehicle,
pushToMobileDe as mockPushToMobileDe,
getMobileDeStatus as mockGetMobileDeStatus,
} from '@/lib/vehicles';
const mockVehicle = {
id: 'vehicle-123',
make: 'Mercedes-Benz',
model: 'Actros',
fin: 'WDB9066351L123456',
year: 2020,
first_registration: '2020-03-15',
power_kw: 300,
power_hp: 408,
fuel_type: 'Diesel',
transmission: 'Manual',
color: 'White',
condition: 'used',
location: 'Berlin',
availability: 'available',
price: 45000.00,
vehicle_type: 'lkw',
lkw_type: 'sattelzugmaschine',
machine_type: null,
body_type: null,
operating_hours: null,
operating_hours_unit: null,
mileage_km: 120000,
description: 'Well maintained truck',
created_at: '2025-01-01T12:00:00Z',
updated_at: '2025-01-01T12:00:00Z',
deleted_at: null,
};
function renderWithProviders(ui: React.ReactElement) {
return render(
<I18nProvider initialLocale="de">
<ToastProvider>
{ui}
</ToastProvider>
</I18nProvider>
);
}
describe('VehicleList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the vehicle list with filters and table', async () => {
(mockListVehicles as any).mockResolvedValue({
items: [mockVehicle],
total: 1,
page: 1,
page_size: 20,
});
renderWithProviders(<VehicleList />);
await waitFor(() => {
expect(screen.getByTestId('vehicle-list')).toBeInTheDocument();
});
expect(screen.getByTestId('vehicle-filters')).toBeInTheDocument();
expect(screen.getByTestId('filter-search')).toBeInTheDocument();
expect(screen.getByTestId('filter-type')).toBeInTheDocument();
expect(screen.getByTestId('filter-availability')).toBeInTheDocument();
expect(screen.getByTestId('filter-sort')).toBeInTheDocument();
});
it('displays vehicles in the table after loading', async () => {
(mockListVehicles as any).mockResolvedValue({
items: [mockVehicle],
total: 1,
page: 1,
page_size: 20,
});
renderWithProviders(<VehicleList />);
await waitFor(() => {
expect(screen.getByText('Mercedes-Benz')).toBeInTheDocument();
expect(screen.getByText('Actros')).toBeInTheDocument();
expect(screen.getByText('WDB9066351L123456')).toBeInTheDocument();
});
});
it('shows pagination when total > page_size', async () => {
(mockListVehicles as any).mockResolvedValue({
items: [mockVehicle],
total: 40,
page: 1,
page_size: 20,
});
renderWithProviders(<VehicleList />);
await waitFor(() => {
expect(screen.getByTestId('vehicle-pagination')).toBeInTheDocument();
expect(screen.getByText(/Page 1 of 2/)).toBeInTheDocument();
});
});
it('shows error message on API failure', async () => {
(mockListVehicles as any).mockRejectedValue({
error: { code: 'UNKNOWN', message: 'Failed to load' },
});
renderWithProviders(<VehicleList />);
await waitFor(() => {
expect(screen.getByTestId('vehicle-error')).toBeInTheDocument();
expect(screen.getByText('Failed to load')).toBeInTheDocument();
});
});
it('calls listVehicles with type filter when changed', async () => {
(mockListVehicles as any).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
renderWithProviders(<VehicleList />);
await waitFor(() => {
expect(mockListVehicles).toHaveBeenCalled();
});
const typeSelect = screen.getByTestId('filter-type');
fireEvent.change(typeSelect, { target: { value: 'lkw' } });
await waitFor(() => {
expect(mockListVehicles).toHaveBeenCalledWith(
expect.objectContaining({ type: 'lkw', page: 1 })
);
});
});
});
describe('VehicleForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the form with all required fields', () => {
renderWithProviders(<VehicleForm mode="create" />);
expect(screen.getByTestId('vehicle-form')).toBeInTheDocument();
expect(screen.getByTestId('input-make')).toBeInTheDocument();
expect(screen.getByTestId('input-model')).toBeInTheDocument();
expect(screen.getByTestId('input-fin')).toBeInTheDocument();
expect(screen.getByTestId('input-price')).toBeInTheDocument();
expect(screen.getByTestId('input-vehicle_type')).toBeInTheDocument();
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
it('shows validation errors for empty required fields', async () => {
renderWithProviders(<VehicleForm mode="create" />);
const submitButton = screen.getByTestId('submit-button');
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Make is required')).toBeInTheDocument();
expect(screen.getByText('Model is required')).toBeInTheDocument();
expect(screen.getByText('FIN is required')).toBeInTheDocument();
});
});
it('shows error for FIN not 17 characters', async () => {
renderWithProviders(<VehicleForm mode="create" />);
const finInput = screen.getByTestId('input-fin');
fireEvent.change(finInput, { target: { value: 'SHORT' } });
const submitButton = screen.getByTestId('submit-button');
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('FIN must be exactly 17 characters')).toBeInTheDocument();
});
});
it('calls createVehicle on submit with valid data', async () => {
(mockCreateVehicle as any).mockResolvedValue(mockVehicle);
renderWithProviders(<VehicleForm mode="create" />);
fireEvent.change(screen.getByTestId('input-make'), { target: { value: 'Volvo' } });
fireEvent.change(screen.getByTestId('input-model'), { target: { value: 'FH16' } });
fireEvent.change(screen.getByTestId('input-fin'), { target: { value: 'WDB9066351L123456' } });
fireEvent.change(screen.getByTestId('input-price'), { target: { value: '85000' } });
fireEvent.click(screen.getByTestId('submit-button'));
await waitFor(() => {
expect(mockCreateVehicle).toHaveBeenCalledWith(
expect.objectContaining({
make: 'Volvo',
model: 'FH16',
fin: 'WDB9066351L123456',
price: 85000,
})
);
});
});
});
describe('VehicleDetail', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders vehicle details after loading', async () => {
(mockGetVehicle as any).mockResolvedValue(mockVehicle);
(mockGetMobileDeStatus as any).mockResolvedValue({
synced: false,
sync_status: 'pending',
});
renderWithProviders(<VehicleDetail vehicleId="vehicle-123" />);
await waitFor(() => {
expect(screen.getByTestId('vehicle-detail')).toBeInTheDocument();
});
expect(screen.getByTestId('vehicle-detail-title')).toHaveTextContent('Mercedes-Benz Actros');
const fieldsContainer = screen.getByTestId('vehicle-detail-fields');
expect(fieldsContainer).toHaveTextContent('WDB9066351L123456');
expect(fieldsContainer).toHaveTextContent('lkw');
expect(fieldsContainer).toHaveTextContent('available');
});
it('shows mobile.de status section', async () => {
(mockGetVehicle as any).mockResolvedValue(mockVehicle);
(mockGetMobileDeStatus as any).mockResolvedValue({
synced: true,
ad_id: 'ad-123',
synced_at: '2025-01-01T12:00:00Z',
sync_status: 'synced',
});
renderWithProviders(<VehicleDetail vehicleId="vehicle-123" />);
await waitFor(() => {
expect(screen.getByTestId('mobile-de-status')).toBeInTheDocument();
});
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Synced');
});
it('shows error message on API failure', async () => {
(mockGetVehicle as any).mockRejectedValue({
error: { code: 'VEHICLE_NOT_FOUND', message: 'Vehicle not found' },
});
renderWithProviders(<VehicleDetail vehicleId="nonexistent" />);
await waitFor(() => {
expect(screen.getByTestId('vehicle-detail-error')).toBeInTheDocument();
});
});
});
describe('MobileDeStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with pending status when no listing exists', async () => {
(mockGetMobileDeStatus as any).mockResolvedValue({
synced: false,
sync_status: 'pending',
});
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
await waitFor(() => {
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Pending');
});
expect(screen.getByTestId('mobile-de-synced')).toHaveTextContent('No');
});
it('shows synced status after successful push', async () => {
(mockGetMobileDeStatus as any).mockResolvedValue({
synced: true,
ad_id: 'ad-456',
synced_at: '2025-06-01T10:00:00Z',
sync_status: 'synced',
});
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
await waitFor(() => {
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Synced');
});
expect(screen.getByTestId('mobile-de-synced')).toHaveTextContent('Yes');
expect(screen.getByText('ad-456')).toBeInTheDocument();
});
it('shows error log when sync failed', async () => {
(mockGetMobileDeStatus as any).mockResolvedValue({
synced: false,
sync_status: 'fehler',
error_log: 'HTTP 500: Internal Server Error',
});
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
await waitFor(() => {
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Error');
expect(screen.getByTestId('mobile-de-error-log')).toBeInTheDocument();
expect(screen.getByText(/HTTP 500/)).toBeInTheDocument();
});
});
it('calls pushToMobileDe when push button is clicked', async () => {
(mockGetMobileDeStatus as any).mockResolvedValue({
synced: false,
sync_status: 'pending',
});
(mockPushToMobileDe as any).mockResolvedValue({
message: 'Push queued',
vehicle_id: 'vehicle-123',
});
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
await waitFor(() => {
expect(screen.getByTestId('mobile-de-push-button')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('mobile-de-push-button'));
await waitFor(() => {
expect(mockPushToMobileDe).toHaveBeenCalledWith('vehicle-123');
});
});
});