feat: add vehicle edit page, image gallery with upload, print and CSV export
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import { VehicleEditForm } from '@/components/vehicles/VehicleEditForm';
|
||||||
|
|
||||||
|
export default async function FahrzeugBearbeitenPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-text mb-6">Fahrzeug bearbeiten</h1>
|
||||||
|
<VehicleEditForm vehicleId={id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,3 +20,71 @@ body {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print\:hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print\:block {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav,
|
||||||
|
aside,
|
||||||
|
button[class*="print:hidden"],
|
||||||
|
[data-testid="vehicle-actions"],
|
||||||
|
[data-testid="vehicle-images"],
|
||||||
|
[data-testid="upload-image-button"],
|
||||||
|
[data-testid="delete-button"],
|
||||||
|
[data-testid="vehicle-filters"],
|
||||||
|
[data-testid="vehicle-pagination"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="vehicle-detail-fields"] {
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="vehicle-detail-fields"] dt {
|
||||||
|
font-weight: bold !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="vehicle-detail-fields"] dd {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1[data-testid="vehicle-detail-title"] {
|
||||||
|
font-size: 18pt !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-surface {
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-border {
|
||||||
|
border-color: #ccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-muted {
|
||||||
|
color: #555 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { VehicleResponse } from '@/lib/vehicles';
|
||||||
|
|
||||||
|
interface VehicleActionsProps {
|
||||||
|
vehicle: VehicleResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvValue(value: string | number | undefined | null): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(filename: string, csvContent: string): void {
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicleToCsvRow(v: VehicleResponse): string {
|
||||||
|
const headers = [
|
||||||
|
'Make', 'Model', 'FIN', 'Year', 'Price', 'Vehicle Type',
|
||||||
|
'Condition', 'Availability', 'Mileage (km)', 'Power (kW)',
|
||||||
|
'Fuel Type', 'Transmission', 'Color', 'Location',
|
||||||
|
'First Registration', 'Description',
|
||||||
|
];
|
||||||
|
return headers.map(h => {
|
||||||
|
switch (h) {
|
||||||
|
case 'Make': return escapeCsvValue(v.make);
|
||||||
|
case 'Model': return escapeCsvValue(v.model);
|
||||||
|
case 'FIN': return escapeCsvValue(v.fin);
|
||||||
|
case 'Year': return escapeCsvValue(v.year);
|
||||||
|
case 'Price': return escapeCsvValue(v.price);
|
||||||
|
case 'Vehicle Type': return escapeCsvValue(v.vehicle_type);
|
||||||
|
case 'Condition': return escapeCsvValue(v.condition);
|
||||||
|
case 'Availability': return escapeCsvValue(v.availability);
|
||||||
|
case 'Mileage (km)': return escapeCsvValue(v.mileage_km);
|
||||||
|
case 'Power (kW)': return escapeCsvValue(v.power_kw);
|
||||||
|
case 'Fuel Type': return escapeCsvValue(v.fuel_type);
|
||||||
|
case 'Transmission': return escapeCsvValue(v.transmission);
|
||||||
|
case 'Color': return escapeCsvValue(v.color);
|
||||||
|
case 'Location': return escapeCsvValue(v.location);
|
||||||
|
case 'First Registration': return escapeCsvValue(v.first_registration);
|
||||||
|
case 'Description': return escapeCsvValue(v.description);
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VehicleActions({ vehicle }: VehicleActionsProps) {
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPdf = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const headers = 'Make,Model,FIN,Year,Price,Vehicle Type,Condition,Availability,Mileage (km),Power (kW),Fuel Type,Transmission,Color,Location,First Registration,Description';
|
||||||
|
const row = vehicleToCsvRow(vehicle);
|
||||||
|
const csv = `${headers}\n${row}`;
|
||||||
|
const filename = `vehicle_${vehicle.fin || vehicle.id}.csv`;
|
||||||
|
downloadCsv(filename, csv);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-actions" className="flex gap-2 print:hidden">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handlePrint}
|
||||||
|
data-testid="print-button"
|
||||||
|
>
|
||||||
|
Drucken
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
data-testid="export-pdf-button"
|
||||||
|
>
|
||||||
|
Als PDF exportieren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
data-testid="export-csv-button"
|
||||||
|
>
|
||||||
|
Als CSV exportieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportVehiclesToCsv(vehicles: VehicleResponse[]): void {
|
||||||
|
const headers = 'Make,Model,FIN,Year,Price,Vehicle Type,Condition,Availability,Mileage (km),Power (kW),Fuel Type,Transmission,Color,Location,First Registration,Description';
|
||||||
|
const rows = vehicles.map(v => vehicleToCsvRow(v));
|
||||||
|
const csv = [headers, ...rows].join('\n');
|
||||||
|
const now = new Date().toISOString().split('T')[0];
|
||||||
|
downloadCsv(`vehicles_export_${now}.csv`, csv);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
type VehicleResponse,
|
type VehicleResponse,
|
||||||
} from '@/lib/vehicles';
|
} from '@/lib/vehicles';
|
||||||
import { MobileDeStatus } from './MobileDeStatus';
|
import { MobileDeStatus } from './MobileDeStatus';
|
||||||
|
import { VehicleImages } from './VehicleImages';
|
||||||
|
import { VehicleActions } from './VehicleActions';
|
||||||
|
|
||||||
interface VehicleDetailProps {
|
interface VehicleDetailProps {
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
@@ -126,6 +128,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VehicleActions vehicle={vehicle} />
|
||||||
|
|
||||||
<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">
|
<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 => (
|
{fields.map(field => (
|
||||||
<div key={field.label} className="space-y-1">
|
<div key={field.label} className="space-y-1">
|
||||||
@@ -137,6 +141,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VehicleImages vehicleId={vehicle.id} />
|
||||||
|
|
||||||
<MobileDeStatus vehicleId={vehicle.id} />
|
<MobileDeStatus vehicleId={vehicle.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { VehicleForm } from './VehicleForm';
|
||||||
|
import { getVehicle, type VehicleResponse } from '@/lib/vehicles';
|
||||||
|
|
||||||
|
interface VehicleEditFormProps {
|
||||||
|
vehicleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VehicleEditForm({ vehicleId }: VehicleEditFormProps) {
|
||||||
|
const [vehicle, setVehicle] = useState<VehicleResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchVehicle() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchVehicle();
|
||||||
|
}, [vehicleId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-edit-loading" className="text-center py-8 text-text-muted">
|
||||||
|
Loading vehicle...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-edit-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vehicle) {
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-edit-not-found" className="text-center py-8 text-text-muted">
|
||||||
|
Vehicle not found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VehicleForm vehicle={vehicle} mode="edit" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import {
|
||||||
|
listFiles,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
isImageMime,
|
||||||
|
getThumbnailUrl,
|
||||||
|
getFileDownloadUrl,
|
||||||
|
type FileResponse,
|
||||||
|
} from '@/lib/files';
|
||||||
|
|
||||||
|
interface VehicleImagesProps {
|
||||||
|
vehicleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VehicleImages({ vehicleId }: VehicleImagesProps) {
|
||||||
|
const [files, setFiles] = useState<FileResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<FileResponse | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fetchFiles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await listFiles(vehicleId, 1, 100);
|
||||||
|
const imageFiles = data.items.filter(f => isImageMime(f.mime_type));
|
||||||
|
setFiles(imageFiles);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to load images');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [vehicleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiles();
|
||||||
|
}, [fetchFiles]);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = e.target.files;
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
|
await uploadFile(vehicleId, selectedFiles[i]);
|
||||||
|
}
|
||||||
|
await fetchFiles();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to upload images');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (fileId: string) => {
|
||||||
|
if (!confirm('Delete this image?')) return;
|
||||||
|
try {
|
||||||
|
await deleteFile(vehicleId, fileId);
|
||||||
|
setFiles(prev => prev.filter(f => f.id !== fileId));
|
||||||
|
if (selectedImage?.id === fileId) {
|
||||||
|
setSelectedImage(null);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to delete image');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-images" className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-text">Bilder</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
data-testid="image-upload-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="upload-image-button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
loading={uploading}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
Bild hochladen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div data-testid="images-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div data-testid="images-loading" className="text-center py-8 text-text-muted">
|
||||||
|
Loading images...
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div data-testid="no-images" className="text-center py-8 text-text-muted">
|
||||||
|
Keine Bilder vorhanden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="image-grid"
|
||||||
|
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
|
{files.map(file => {
|
||||||
|
const thumbUrl = getThumbnailUrl(file) || getFileDownloadUrl(vehicleId, file.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
data-testid={`image-item-${file.id}`}
|
||||||
|
className="relative group rounded-lg overflow-hidden border border-border bg-surface"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedImage(file)}
|
||||||
|
className="block w-full aspect-square"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={thumbUrl}
|
||||||
|
alt={file.original_filename}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid={`delete-image-${file.id}`}
|
||||||
|
onClick={() => handleDelete(file.id)}
|
||||||
|
className="absolute top-2 right-2 bg-error text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Delete image"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={selectedImage !== null}
|
||||||
|
onClose={() => setSelectedImage(null)}
|
||||||
|
title={selectedImage?.original_filename}
|
||||||
|
>
|
||||||
|
{selectedImage && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getFileDownloadUrl(vehicleId, selectedImage.id)}
|
||||||
|
alt={selectedImage.original_filename}
|
||||||
|
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(selectedImage.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type VehicleListParams,
|
type VehicleListParams,
|
||||||
} from '@/lib/vehicles';
|
} from '@/lib/vehicles';
|
||||||
import type { PaginatedResponse } from '@/lib/api';
|
import type { PaginatedResponse } from '@/lib/api';
|
||||||
|
import { exportVehiclesToCsv } from './VehicleActions';
|
||||||
|
|
||||||
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||||
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
||||||
@@ -31,6 +32,7 @@ export function VehicleList() {
|
|||||||
const [pageSize] = useState(20);
|
const [pageSize] = useState(20);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<VehicleListParams>({
|
const [filters, setFilters] = useState<VehicleListParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -69,6 +71,23 @@ export function VehicleList() {
|
|||||||
setFilters(prev => ({ ...prev, page: newPage }));
|
setFilters(prev => ({ ...prev, page: newPage }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportAllCsv = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data: PaginatedResponse<VehicleResponse> = await listVehicles({
|
||||||
|
page: 1,
|
||||||
|
page_size: 1000,
|
||||||
|
});
|
||||||
|
exportVehiclesToCsv(data.items);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to export vehicles');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'make',
|
key: 'make',
|
||||||
@@ -101,10 +120,21 @@ export function VehicleList() {
|
|||||||
<div data-testid="vehicle-list" className="space-y-4">
|
<div data-testid="vehicle-list" className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleExportAllCsv}
|
||||||
|
loading={exporting}
|
||||||
|
disabled={exporting}
|
||||||
|
data-testid="export-all-csv-button"
|
||||||
|
>
|
||||||
|
Alle als CSV exportieren
|
||||||
|
</Button>
|
||||||
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
||||||
+ Neues Fahrzeug
|
+ Neues Fahrzeug
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
|
|||||||
@@ -47,5 +47,14 @@
|
|||||||
"common.confirm": "Bestätigen",
|
"common.confirm": "Bestätigen",
|
||||||
"user.role.admin": "Administrator",
|
"user.role.admin": "Administrator",
|
||||||
"user.role.verkaeufer": "Verkäufer",
|
"user.role.verkaeufer": "Verkäufer",
|
||||||
"user.role.buchhaltung": "Buchhaltung"
|
"user.role.buchhaltung": "Buchhaltung",
|
||||||
|
"vehicle.edit": "Bearbeiten",
|
||||||
|
"vehicle.print": "Drucken",
|
||||||
|
"vehicle.exportPdf": "Als PDF exportieren",
|
||||||
|
"vehicle.exportCsv": "Als CSV exportieren",
|
||||||
|
"vehicle.images": "Bilder",
|
||||||
|
"vehicle.uploadImage": "Bild hochladen",
|
||||||
|
"vehicle.deleteImage": "Bild löschen",
|
||||||
|
"vehicle.noImages": "Keine Bilder vorhanden",
|
||||||
|
"vehicle.exportAllCsv": "Alle als CSV exportieren"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,14 @@
|
|||||||
"common.confirm": "Confirm",
|
"common.confirm": "Confirm",
|
||||||
"user.role.admin": "Administrator",
|
"user.role.admin": "Administrator",
|
||||||
"user.role.verkaeufer": "Sales",
|
"user.role.verkaeufer": "Sales",
|
||||||
"user.role.buchhaltung": "Accounting"
|
"user.role.buchhaltung": "Accounting",
|
||||||
|
"vehicle.edit": "Edit",
|
||||||
|
"vehicle.print": "Print",
|
||||||
|
"vehicle.exportPdf": "Export as PDF",
|
||||||
|
"vehicle.exportCsv": "Export as CSV",
|
||||||
|
"vehicle.images": "Images",
|
||||||
|
"vehicle.uploadImage": "Upload Image",
|
||||||
|
"vehicle.deleteImage": "Delete Image",
|
||||||
|
"vehicle.noImages": "No images available",
|
||||||
|
"vehicle.exportAllCsv": "Export All as CSV"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user