feat(07-05): VehicleTable + CsvImportButton + vehicles page — GREEN (DKV-03)
- 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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-1
@@ -123,7 +123,8 @@ describe('VehicleTable', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking pencil icon switches row to edit mode; saving calls updateVehicle', async () => {
|
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' },
|
{ id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' },
|
||||||
]);
|
]);
|
||||||
mockUpdateVehicle.mockResolvedValue({
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user