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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user