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 () => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user