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