feat(07-05): VehicleTable + CsvImportButton + vehicles page — GREEN (DKV-03)
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 42s
Tessera CI/CD / Tests (push) Waiting to run

- 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)
This commit is contained in:
2026-06-27 00:41:03 +02:00
parent c739d2788b
commit 4d81b8b2d5
4 changed files with 802 additions and 1 deletions
@@ -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<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [mode, setMode] = useState<ImportMode>('merge');
const [showDialog, setShowDialog] = useState(false);
const [showReplaceConfirm, setShowReplaceConfirm] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importSuccess, setImportSuccess] = useState<string | null>(null);
const handleButtonClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="relative">
{/* Hidden file input — T-07-15: accept=".csv" */}
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleFileChange}
/>
{/* Visible trigger button */}
<button
type="button"
onClick={handleButtonClick}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
{t('importCsv')}
</button>
{/* Import mode inline dialog (below button, per UI-SPEC) */}
{showDialog && selectedFile && (
<div className="absolute right-0 top-10 z-30 w-80 rounded border border-border bg-card p-4 shadow-md">
<p className="text-sm font-medium text-foreground mb-3">
{t('importMode')}
</p>
{/* Mode selection */}
<div className="space-y-2 mb-4">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="csv-mode"
value="merge"
checked={mode === 'merge'}
onChange={() => {
setMode('merge');
setShowReplaceConfirm(false);
}}
className="mt-0.5"
/>
<span className="text-sm text-foreground">
{t('importMerge')}
</span>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="csv-mode"
value="replace"
checked={mode === 'replace'}
onChange={() => {
setMode('replace');
setShowReplaceConfirm(false);
}}
className="mt-0.5"
/>
<span className="text-sm text-foreground">
{t('importReplace')}
</span>
</label>
{mode === 'replace' && (
<p className="ml-6 text-xs text-destructive">
{t('importReplaceWarning')}
</p>
)}
</div>
{/* Destructive confirmation for "Ersetzen" */}
{showReplaceConfirm && mode === 'replace' && (
<div className="mb-4 rounded border border-destructive/30 bg-destructive/10 px-3 py-2">
<p className="text-sm font-medium text-destructive mb-1">
{t('importReplaceConfirmTitle')}
</p>
<p className="text-xs text-destructive">
{t('importReplaceConfirmBody')}
</p>
</div>
)}
{/* Error display */}
{importError && (
<p className="mb-3 text-sm text-destructive">{importError}</p>
)}
{/* Actions */}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={handleCancel}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{t('form.cancelDialog')}
</button>
<button
type="button"
onClick={handleImport}
disabled={isImporting}
className={`rounded px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
mode === 'replace' && showReplaceConfirm
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
}`}
>
{isImporting
? 'Wird importiert...'
: mode === 'replace' && showReplaceConfirm
? t('importReplaceConfirmButton')
: 'Importieren'}
</button>
</div>
</div>
)}
{/* Success message (post-import) */}
{importSuccess && !showDialog && (
<p
className="mt-2 text-sm"
style={{ color: 'oklch(0.40 0.15 148)' }}
>
{importSuccess}
</p>
)}
</div>
);
}
@@ -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({
@@ -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 (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 2.5l2.5 2.5L4 14.5H1.5V12L11 2.5z" />
</svg>
);
}
function TrashIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="2,4 14,4" />
<path d="M5 4V2h6v2" />
<rect x="3" y="4" width="10" height="10" rx="1" />
<line x1="6" y1="7" x2="6" y2="11" />
<line x1="10" y1="7" x2="10" y2="11" />
</svg>
);
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="2,8 6,12 14,4" />
</svg>
);
}
function XIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="3" y1="3" x2="13" y2="13" />
<line x1="13" y1="3" x2="3" y2="13" />
</svg>
);
}
// --- 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="rounded border border-border bg-card px-6 py-5 shadow-lg max-w-sm w-full">
<h3 className="text-base font-semibold text-foreground mb-2">
{t('deleteVehicle')}
</h3>
<p className="text-sm text-muted-foreground mb-5">
{t('deleteVehicleBody', { plate })}
</p>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted"
>
{t('form.cancelDialog')}
</button>
<button
type="button"
onClick={onConfirm}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
Löschen
</button>
</div>
</div>
</div>
);
}
// --- Toast ---
interface ToastProps {
message: string;
}
function Toast({ message }: ToastProps) {
return (
<div className="fixed bottom-4 right-4 z-50 rounded border border-border bg-card shadow-md px-4 py-3 text-sm">
{message}
</div>
);
}
/**
* 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<DkvVehicle[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Row edit state: vehicleId → EditState (null = new row)
const [editId, setEditId] = useState<string | null>(null);
const [editState, setEditState] = useState<EditState>(EMPTY_EDIT);
const [editError, setEditError] = useState<string | null>(null);
const [isSavingRow, setIsSavingRow] = useState(false);
// New row
const [isAddingNew, setIsAddingNew] = useState(false);
const [newRow, setNewRow] = useState<EditState>(EMPTY_EDIT);
const [newRowError, setNewRowError] = useState<string | null>(null);
const [isSavingNew, setIsSavingNew] = useState(false);
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<DkvVehicle | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Server error below table
const [tableError, setTableError] = useState<string | null>(null);
// Toast
const [toast, setToast] = useState<string | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | 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 */}
<div className="flex justify-between items-center mb-3">
<span className="text-sm text-muted-foreground">
{t('vehicleCount', { count: vehicles.length })}
</span>
<div className="flex gap-2">
<CsvImportButton onImported={handleImported} />
<button
type="button"
onClick={startAdd}
disabled={isAddingNew}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('addVehicle')}
</button>
</div>
</div>
{/* Table */}
<div className="rounded border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/30">
<tr>
<th className={theadCls} style={{ width: 140 }}>{t('col.licensePlate')}</th>
<th className={theadCls} style={{ width: 140 }}>{t('col.brand')}</th>
<th className={theadCls} style={{ width: 180 }}>{t('col.model')}</th>
<th className={theadCls} style={{ width: 200 }}>{t('col.driver')}</th>
<th className={theadCls} style={{ width: 80 }}>{t('col.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b border-border last:border-0">
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-3 py-3">
<div className="h-4 rounded bg-muted animate-pulse" />
</td>
))}
</tr>
))
) : vehicles.length === 0 && !isAddingNew ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center">
<p className="font-medium text-foreground">{t('emptyVehicles')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('emptyVehiclesBody')}
</p>
</td>
</tr>
) : (
vehicles.map((v) =>
editId === v.id ? (
/* Edit mode row */
<tr key={v.id} className="border-b border-border last:border-0 bg-muted/20">
<td className="px-3 py-2">
<input
type="text"
className={`${editInputCls} uppercase`}
value={editState.kennzeichen}
onChange={(e) =>
setEditState((s) => ({ ...s, kennzeichen: e.target.value.toUpperCase() }))
}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className={editInputCls}
value={editState.marke}
onChange={(e) =>
setEditState((s) => ({ ...s, marke: e.target.value }))
}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className={editInputCls}
value={editState.modell}
onChange={(e) =>
setEditState((s) => ({ ...s, modell: e.target.value }))
}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className={editInputCls}
value={editState.fahrer}
onChange={(e) =>
setEditState((s) => ({ ...s, fahrer: e.target.value }))
}
/>
</td>
<td className="px-3 py-2">
<div className="flex gap-1">
<button
type="button"
aria-label="Änderungen speichern"
onClick={() => saveEdit(v.id)}
disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`}
>
<CheckIcon />
</button>
<button
type="button"
aria-label="Bearbeitung abbrechen"
onClick={cancelEdit}
className={iconBtnCls}
>
<XIcon />
</button>
</div>
{editError && (
<p className="text-destructive text-xs mt-1">{editError}</p>
)}
</td>
</tr>
) : (
/* View mode row */
<tr
key={v.id}
className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
>
<td className="px-3 py-3 text-sm">{v.kennzeichen}</td>
<td className="px-3 py-3 text-sm">{v.marke}</td>
<td className="px-3 py-3 text-sm">{v.modell}</td>
<td className="px-3 py-3 text-sm">{v.fahrer}</td>
<td className="px-3 py-3">
<div className="flex gap-1">
<button
type="button"
aria-label="Fahrzeug bearbeiten"
onClick={() => startEdit(v)}
className={iconBtnCls}
>
<PencilIcon />
</button>
<button
type="button"
aria-label="Fahrzeug löschen"
onClick={() => setDeleteTarget(v)}
className={`${iconBtnCls} hover:text-destructive`}
>
<TrashIcon />
</button>
</div>
</td>
</tr>
),
)
)}
{/* New row (appended when "Fahrzeug hinzufügen" is clicked) */}
{isAddingNew && (
<tr className="border-b border-border last:border-0 bg-muted/20">
<td className="px-3 py-2">
<input
type="text"
className={`${editInputCls} uppercase`}
placeholder="M-AB 123"
value={newRow.kennzeichen}
onChange={(e) =>
setNewRow((s) => ({
...s,
kennzeichen: e.target.value.toUpperCase(),
}))
}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className={editInputCls}
value={newRow.marke}
onChange={(e) =>
setNewRow((s) => ({ ...s, marke: e.target.value }))
}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className={editInputCls}
value={newRow.modell}
onChange={(e) =>
setNewRow((s) => ({ ...s, modell: e.target.value }))
}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className={editInputCls}
value={newRow.fahrer}
onChange={(e) =>
setNewRow((s) => ({ ...s, fahrer: e.target.value }))
}
/>
</td>
<td className="px-3 py-2">
<div className="flex gap-1">
<button
type="button"
aria-label="Änderungen speichern"
onClick={saveNew}
disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`}
>
<CheckIcon />
</button>
<button
type="button"
aria-label="Bearbeitung abbrechen"
onClick={cancelAdd}
className={iconBtnCls}
>
<XIcon />
</button>
</div>
{newRowError && (
<p className="text-destructive text-xs mt-1">{newRowError}</p>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Server error below table */}
{tableError && (
<p className="mt-2 text-sm text-destructive">{tableError}</p>
)}
{/* Delete confirm dialog */}
{deleteTarget && (
<DeleteDialog
plate={deleteTarget.kennzeichen}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
{/* Toast */}
{toast && <Toast message={toast} />}
</>
);
}
@@ -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 (
<div className="mx-auto max-w-5xl p-6">
{/* Back link to settings */}
<div className="mb-4">
<Link
href="/modules/dkv-fleet/settings"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
← {t('tabs.inbox')} / {t('settingsTitle')}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight mb-6">
{t('tabs.vehicles')}
</h1>
<VehicleTable />
</div>
);
}