From 4d81b8b2d579e88741eda30db49de227c50909de Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 27 Jun 2026 00:41:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(07-05):=20VehicleTable=20+=20CsvImportButt?= =?UTF-8?q?on=20+=20vehicles=20page=20=E2=80=94=20GREEN=20(DKV-03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VehicleTable: list, inline edit, create row, delete with confirm dialog - All 4 UI-SPEC aria-labels on icon-only buttons (Rule 2: accessibility correctness) - CsvImportButton: merge/replace modes, destructive confirm, importVehiclesCsv - vehicles/page.tsx: heading + back link to settings + VehicleTable - VehicleTable.test.tsx: fix mock sequencing bug (mockResolvedValueOnce for init load) - All 5 Vitest tests pass (GREEN) --- .../settings/components/CsvImportButton.tsx | 218 +++++++ .../settings/components/VehicleTable.test.tsx | 3 +- .../settings/components/VehicleTable.tsx | 548 ++++++++++++++++++ .../modules/dkv-fleet/vehicles/page.tsx | 34 ++ 4 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx create mode 100644 apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx create mode 100644 apps/web/src/app/(portal)/modules/dkv-fleet/vehicles/page.tsx diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx new file mode 100644 index 0000000..bc2d7e3 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { importVehiclesCsv } from '@/lib/dkv-api'; + +interface CsvImportButtonProps { + /** Called after a successful import with the number of imported vehicles */ + onImported: (count: number) => void; +} + +type ImportMode = 'merge' | 'replace'; + +/** + * CsvImportButton — handles CSV vehicle import with merge/replace modes. + * T-07-15: client restricts to .csv (accept=".csv"), replace requires explicit confirm. + * Server-side validation is authoritative; client-side is UX-only. + */ +export function CsvImportButton({ onImported }: CsvImportButtonProps) { + const t = useTranslations('dkvFleet'); + const fileInputRef = useRef(null); + + const [selectedFile, setSelectedFile] = useState(null); + const [mode, setMode] = useState('merge'); + const [showDialog, setShowDialog] = useState(false); + const [showReplaceConfirm, setShowReplaceConfirm] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [importSuccess, setImportSuccess] = useState(null); + + const handleButtonClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Client-side CSV validation (T-07-15) + if (!file.name.endsWith('.csv')) { + setImportError('Bitte wähle eine .csv-Datei aus.'); + return; + } + + setSelectedFile(file); + setMode('merge'); + setImportError(null); + setImportSuccess(null); + setShowDialog(true); + setShowReplaceConfirm(false); + + // Reset input so the same file can be selected again + e.target.value = ''; + }; + + const handleImport = async () => { + if (!selectedFile) return; + + // "Ersetzen" requires destructive confirmation + if (mode === 'replace' && !showReplaceConfirm) { + setShowReplaceConfirm(true); + return; + } + + setIsImporting(true); + setImportError(null); + try { + const result = await importVehiclesCsv(selectedFile, mode); + setShowDialog(false); + setShowReplaceConfirm(false); + setSelectedFile(null); + setImportSuccess(t('importSuccess', { count: result.count })); + onImported(result.count); + } catch (err) { + setImportError( + err instanceof Error ? err.message : 'Import fehlgeschlagen.', + ); + } finally { + setIsImporting(false); + } + }; + + const handleCancel = () => { + setShowDialog(false); + setShowReplaceConfirm(false); + setSelectedFile(null); + setImportError(null); + setMode('merge'); + }; + + return ( +
+ {/* Hidden file input — T-07-15: accept=".csv" */} + + + {/* Visible trigger button */} + + + {/* Import mode inline dialog (below button, per UI-SPEC) */} + {showDialog && selectedFile && ( +
+

+ {t('importMode')} +

+ + {/* Mode selection */} +
+ + + + + {mode === 'replace' && ( +

+ {t('importReplaceWarning')} +

+ )} +
+ + {/* Destructive confirmation for "Ersetzen" */} + {showReplaceConfirm && mode === 'replace' && ( +
+

+ {t('importReplaceConfirmTitle')} +

+

+ {t('importReplaceConfirmBody')} +

+
+ )} + + {/* Error display */} + {importError && ( +

{importError}

+ )} + + {/* Actions */} +
+ + +
+
+ )} + + {/* Success message (post-import) */} + {importSuccess && !showDialog && ( +

+ {importSuccess} +

+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx index 0f1f445..4bf3386 100644 --- a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx @@ -123,7 +123,8 @@ describe('VehicleTable', () => { }); it('clicking pencil icon switches row to edit mode; saving calls updateVehicle', async () => { - mockFetchVehicles.mockResolvedValue([ + // Use mockResolvedValueOnce for first load (Hans), then mockResolvedValue for refetch (Max) + mockFetchVehicles.mockResolvedValueOnce([ { id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' }, ]); mockUpdateVehicle.mockResolvedValue({ diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx new file mode 100644 index 0000000..43ba954 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx @@ -0,0 +1,548 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + type DkvVehicle, + type CreateVehiclePayload, + type UpdateVehiclePayload, + fetchVehicles, + createVehicle, + updateVehicle, + deleteVehicle, +} from '@/lib/dkv-api'; +import { CsvImportButton } from './CsvImportButton'; + +// --- Inline SVG icons (no external icon package per codebase convention) --- + +function PencilIcon() { + return ( + + ); +} + +function TrashIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function XIcon() { + return ( + + ); +} + +// --- Types --- + +interface EditState { + kennzeichen: string; + marke: string; + modell: string; + fahrer: string; +} + +const EMPTY_EDIT: EditState = { + kennzeichen: '', + marke: '', + modell: '', + fahrer: '', +}; + +const vehicleToEdit = (v: DkvVehicle): EditState => ({ + kennzeichen: v.kennzeichen, + marke: v.marke, + modell: v.modell, + fahrer: v.fahrer, +}); + +// --- Shared classes --- +const theadCls = + 'px-3 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left'; +const iconBtnCls = + 'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors'; +const editInputCls = + 'h-9 w-full rounded border border-border bg-background px-2 text-sm text-foreground'; + +// --- Delete confirm dialog --- +interface DeleteDialogProps { + plate: string; + onConfirm: () => void; + onCancel: () => void; +} + +function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) { + const t = useTranslations('dkvFleet'); + return ( +
+
+

+ {t('deleteVehicle')} +

+

+ {t('deleteVehicleBody', { plate })} +

+
+ + +
+
+
+ ); +} + +// --- Toast --- +interface ToastProps { + message: string; +} + +function Toast({ message }: ToastProps) { + return ( +
+ {message} +
+ ); +} + +/** + * VehicleTable — Vehicle master CRUD table (Surface B Tab 2, DKV-03). + * Features: list, inline edit, create (append empty row), delete with confirm. + * T-07-14: all strings via React text nodes (no dangerouslySetInnerHTML). + * T-07-15: CSV import validation in CsvImportButton. + */ +export function VehicleTable() { + const t = useTranslations('dkvFleet'); + + const [vehicles, setVehicles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Row edit state: vehicleId → EditState (null = new row) + const [editId, setEditId] = useState(null); + const [editState, setEditState] = useState(EMPTY_EDIT); + const [editError, setEditError] = useState(null); + const [isSavingRow, setIsSavingRow] = useState(false); + + // New row + const [isAddingNew, setIsAddingNew] = useState(false); + const [newRow, setNewRow] = useState(EMPTY_EDIT); + const [newRowError, setNewRowError] = useState(null); + const [isSavingNew, setIsSavingNew] = useState(false); + + // Delete confirm + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Server error below table + const [tableError, setTableError] = useState(null); + + // Toast + const [toast, setToast] = useState(null); + const toastTimerRef = useRef | null>(null); + + const showToast = useCallback((msg: string) => { + setToast(msg); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + toastTimerRef.current = setTimeout(() => setToast(null), 4000); + }, []); + + const load = useCallback(async () => { + try { + const data = await fetchVehicles(); + setVehicles(data); + } catch { + setTableError('Fahrzeuge konnten nicht geladen werden.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // --- Edit existing row --- + const startEdit = (v: DkvVehicle) => { + setEditId(v.id); + setEditState(vehicleToEdit(v)); + setEditError(null); + setIsAddingNew(false); + }; + + const cancelEdit = () => { + setEditId(null); + setEditState(EMPTY_EDIT); + setEditError(null); + }; + + const saveEdit = async (id: string) => { + if (!editState.kennzeichen.trim()) { + setEditError('Kennzeichen ist erforderlich.'); + return; + } + setIsSavingRow(true); + setEditError(null); + try { + const payload: UpdateVehiclePayload = { + kennzeichen: editState.kennzeichen.trim().toUpperCase(), + marke: editState.marke.trim(), + modell: editState.modell.trim(), + fahrer: editState.fahrer.trim(), + }; + await updateVehicle(id, payload); + setEditId(null); + await load(); + } catch { + setEditError('Speichern fehlgeschlagen.'); + } finally { + setIsSavingRow(false); + } + }; + + // --- Add new row --- + const startAdd = () => { + setIsAddingNew(true); + setNewRow(EMPTY_EDIT); + setNewRowError(null); + setEditId(null); + }; + + const cancelAdd = () => { + setIsAddingNew(false); + setNewRow(EMPTY_EDIT); + setNewRowError(null); + }; + + const saveNew = async () => { + if (!newRow.kennzeichen.trim()) { + setNewRowError('Kennzeichen ist erforderlich.'); + return; + } + setIsSavingNew(true); + setNewRowError(null); + try { + const payload: CreateVehiclePayload = { + kennzeichen: newRow.kennzeichen.trim().toUpperCase(), + marke: newRow.marke.trim(), + modell: newRow.modell.trim(), + fahrer: newRow.fahrer.trim(), + }; + await createVehicle(payload); + setIsAddingNew(false); + setNewRow(EMPTY_EDIT); + await load(); + } catch { + setNewRowError('Fahrzeug konnte nicht hinzugefügt werden.'); + } finally { + setIsSavingNew(false); + } + }; + + // --- Delete --- + const confirmDelete = async () => { + if (!deleteTarget) return; + setIsDeleting(true); + try { + await deleteVehicle(deleteTarget.id); + setDeleteTarget(null); + await load(); + } catch { + setTableError('Fahrzeug konnte nicht gelöscht werden.'); + setDeleteTarget(null); + } finally { + setIsDeleting(false); + } + }; + + // --- CSV import callback --- + const handleImported = useCallback( + async (count: number) => { + await load(); + showToast(t('importSuccess', { count })); + }, + [load, showToast, t], + ); + + return ( + <> + {/* Toolbar */} +
+ + {t('vehicleCount', { count: vehicles.length })} + +
+ + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + ))} + + )) + ) : vehicles.length === 0 && !isAddingNew ? ( + + + + ) : ( + vehicles.map((v) => + editId === v.id ? ( + /* Edit mode row */ + + + + + + + + ) : ( + /* View mode row */ + + + + + + + + ), + ) + )} + + {/* New row (appended when "Fahrzeug hinzufügen" is clicked) */} + {isAddingNew && ( + + + + + + + + )} + +
{t('col.licensePlate')}{t('col.brand')}{t('col.model')}{t('col.driver')}{t('col.actions')}
+
+
+

{t('emptyVehicles')}

+

+ {t('emptyVehiclesBody')} +

+
+ + setEditState((s) => ({ ...s, kennzeichen: e.target.value.toUpperCase() })) + } + /> + + + setEditState((s) => ({ ...s, marke: e.target.value })) + } + /> + + + setEditState((s) => ({ ...s, modell: e.target.value })) + } + /> + + + setEditState((s) => ({ ...s, fahrer: e.target.value })) + } + /> + +
+ + +
+ {editError && ( +

{editError}

+ )} +
{v.kennzeichen}{v.marke}{v.modell}{v.fahrer} +
+ + +
+
+ + setNewRow((s) => ({ + ...s, + kennzeichen: e.target.value.toUpperCase(), + })) + } + /> + + + setNewRow((s) => ({ ...s, marke: e.target.value })) + } + /> + + + setNewRow((s) => ({ ...s, modell: e.target.value })) + } + /> + + + setNewRow((s) => ({ ...s, fahrer: e.target.value })) + } + /> + +
+ + +
+ {newRowError && ( +

{newRowError}

+ )} +
+
+ + {/* Server error below table */} + {tableError && ( +

{tableError}

+ )} + + {/* Delete confirm dialog */} + {deleteTarget && ( + setDeleteTarget(null)} + /> + )} + + {/* Toast */} + {toast && } + + ); +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/vehicles/page.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/vehicles/page.tsx new file mode 100644 index 0000000..bf64582 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/vehicles/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { VehicleTable } from '../settings/components/VehicleTable'; + +/** + * DKV Fleet Vehicles Page — Surface B, Tab 2 (DKV-03). + * Vehicle master CRUD table: list, inline edit, create, delete, CSV import. + * Linked from /modules/dkv-fleet/settings (tab 2 navigation). + */ +export default function DkvFleetVehiclesPage() { + const t = useTranslations('dkvFleet'); + + return ( +
+ {/* Back link to settings */} +
+ + ← {t('tabs.inbox')} / {t('settingsTitle')} + +
+ +

+ {t('tabs.vehicles')} +

+ + +
+ ); +}