From 563127c75adcecc1db72d1de8b2ec684173690ec Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Sun, 19 Jul 2026 19:47:39 +0200 Subject: [PATCH] feat: add vehicle edit page, image gallery with upload, print and CSV export --- .../fahrzeuge/[id]/bearbeiten/page.tsx | 15 ++ frontend/app/globals.css | 68 +++++++ .../components/vehicles/VehicleActions.tsx | 111 +++++++++++ .../components/vehicles/VehicleDetail.tsx | 6 + .../components/vehicles/VehicleEditForm.tsx | 58 ++++++ .../components/vehicles/VehicleImages.tsx | 188 ++++++++++++++++++ frontend/components/vehicles/VehicleList.tsx | 36 +++- frontend/messages/de.json | 11 +- frontend/messages/en.json | 11 +- 9 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 frontend/app/[locale]/fahrzeuge/[id]/bearbeiten/page.tsx create mode 100644 frontend/components/vehicles/VehicleActions.tsx create mode 100644 frontend/components/vehicles/VehicleEditForm.tsx create mode 100644 frontend/components/vehicles/VehicleImages.tsx diff --git a/frontend/app/[locale]/fahrzeuge/[id]/bearbeiten/page.tsx b/frontend/app/[locale]/fahrzeuge/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..659595e --- /dev/null +++ b/frontend/app/[locale]/fahrzeuge/[id]/bearbeiten/page.tsx @@ -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 ( +
+

Fahrzeug bearbeiten

+ +
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a4e814e..b36fabf 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -20,3 +20,71 @@ body { color: var(--color-text); 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; + } +} diff --git a/frontend/components/vehicles/VehicleActions.tsx b/frontend/components/vehicles/VehicleActions.tsx new file mode 100644 index 0000000..2b4ca5f --- /dev/null +++ b/frontend/components/vehicles/VehicleActions.tsx @@ -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 ( +
+ + + +
+ ); +} + +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); +} diff --git a/frontend/components/vehicles/VehicleDetail.tsx b/frontend/components/vehicles/VehicleDetail.tsx index 81930c2..b980f6a 100644 --- a/frontend/components/vehicles/VehicleDetail.tsx +++ b/frontend/components/vehicles/VehicleDetail.tsx @@ -9,6 +9,8 @@ import { type VehicleResponse, } from '@/lib/vehicles'; import { MobileDeStatus } from './MobileDeStatus'; +import { VehicleImages } from './VehicleImages'; +import { VehicleActions } from './VehicleActions'; interface VehicleDetailProps { vehicleId: string; @@ -126,6 +128,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) { + +
{fields.map(field => (
@@ -137,6 +141,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) { ))}
+ +
); diff --git a/frontend/components/vehicles/VehicleEditForm.tsx b/frontend/components/vehicles/VehicleEditForm.tsx new file mode 100644 index 0000000..49c18da --- /dev/null +++ b/frontend/components/vehicles/VehicleEditForm.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ Loading vehicle... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!vehicle) { + return ( +
+ Vehicle not found +
+ ); + } + + return ; +} diff --git a/frontend/components/vehicles/VehicleImages.tsx b/frontend/components/vehicles/VehicleImages.tsx new file mode 100644 index 0000000..5ebaa26 --- /dev/null +++ b/frontend/components/vehicles/VehicleImages.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [selectedImage, setSelectedImage] = useState(null); + const fileInputRef = useRef(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) => { + 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 ( +
+
+

Bilder

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Loading images... +
+ ) : files.length === 0 ? ( +
+ Keine Bilder vorhanden +
+ ) : ( +
+ {files.map(file => { + const thumbUrl = getThumbnailUrl(file) || getFileDownloadUrl(vehicleId, file.id); + return ( +
+ + +
+ ); + })} +
+ )} + + setSelectedImage(null)} + title={selectedImage?.original_filename} + > + {selectedImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {selectedImage.original_filename} +
+ +
+
+ )} +
+
+ ); +} diff --git a/frontend/components/vehicles/VehicleList.tsx b/frontend/components/vehicles/VehicleList.tsx index 1d23501..c4891ba 100644 --- a/frontend/components/vehicles/VehicleList.tsx +++ b/frontend/components/vehicles/VehicleList.tsx @@ -11,6 +11,7 @@ import { type VehicleListParams, } from '@/lib/vehicles'; import type { PaginatedResponse } from '@/lib/api'; +import { exportVehiclesToCsv } from './VehicleActions'; const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter']; const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold']; @@ -31,6 +32,7 @@ export function VehicleList() { const [pageSize] = useState(20); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); const [filters, setFilters] = useState({ page: 1, @@ -69,6 +71,23 @@ export function VehicleList() { setFilters(prev => ({ ...prev, page: newPage })); }; + const handleExportAllCsv = async () => { + setExporting(true); + setError(null); + try { + const data: PaginatedResponse = 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 = [ { key: 'make', @@ -101,9 +120,20 @@ export function VehicleList() {

Fahrzeuge

- +
+ + +
diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2186a9d..e9a118b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -47,5 +47,14 @@ "common.confirm": "Bestätigen", "user.role.admin": "Administrator", "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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 980b89f..0daa508 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -47,5 +47,14 @@ "common.confirm": "Confirm", "user.role.admin": "Administrator", "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" }