fix(web): Doppelausloesung des Loeschknopfs sperren, Fahrzeugtabelle uebersetzt

VehicleTable.confirmDelete liess das Beschaeftigt-Kennzeichen zwar setzen,
aber nie lesen (const [, setIsDeleting]); Dialogschaltflaechen blieben
waehrend der laufenden Loeschanfrage bedienbar. Kennzeichen jetzt lesbar
gebunden, Dialog reicht den Zustand an Bestaetigen/Abbrechen weiter
(disabled + Sperr-Klassen), und confirmDelete bricht bei bereits laufender
Loeschung selbst ab.

Alle sieben fest verdrahteten Texte und sechs Vorlesehilfen der Tabelle
jetzt ueber next-intl (sieben neue Schluessel im Bereich dkvFleet, gleicher
Schluesselsatz in de.json und en.json).

Nebenbefund beim Testen: `load` haette mit `t` als Abhaengigkeit bei einem
instabilen Uebersetzer-Mock einen Abruf-bei-jedem-Render-Zyklus ausgeloest
— bewusst mit leerem Abhaengigkeitsfeld gelassen. Ausserdem
`vi.restoreAllMocks()` im Testabbau ersetzt: es leerte die Aufrufzaehlung
der reinen vi.fn()-Mocks nicht, wodurch der neue Doppelklick-Test falsche
Aufrufzahlen sah.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 11:39:03 +02:00
parent f7c02b7fc9
commit e56cce4bf3
4 changed files with 135 additions and 24 deletions
@@ -22,6 +22,7 @@ vi.mock('next-intl', () => ({
'form.cancelDialog': 'Abbrechen',
'form.save': 'Speichern',
'form.cancel': 'Bearbeitung abbrechen',
'form.saveRow': 'Änderungen speichern',
},
};
// Support dot-notation keys
@@ -47,7 +48,14 @@ vi.mock('@/lib/dkv-api', () => ({
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// vi.restoreAllMocks() does not reset the call history of plain vi.fn()
// mocks (no "original" implementation to restore to) — mockDeleteVehicle's
// call count was leaking across tests, which the new double-click test
// below (toHaveBeenCalledTimes) exposed. vi.clearAllMocks() clears
// mock.calls/mock.results on every mock; each test sets its own
// mockResolvedValue*/mockImplementation before rendering, so leftover
// implementations from the previous test are harmless.
vi.clearAllMocks();
});
describe('VehicleTable', () => {
@@ -113,8 +121,9 @@ describe('VehicleTable', () => {
expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument();
});
// Confirm the deletion
const confirmBtn = screen.getByRole('button', { name: 'Löschen' });
// Confirm the deletion — button label comes from the (unmapped) key
// 'form.deleteConfirm', proving the text is no longer hardcoded.
const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' });
fireEvent.click(confirmBtn);
await waitFor(() => {
@@ -202,4 +211,86 @@ describe('VehicleTable', () => {
expect(screen.getByRole('button', { name: 'Bearbeitung abbrechen' })).toBeInTheDocument();
});
});
it('double-clicking the delete confirm button triggers exactly one deleteVehicle call', async () => {
mockFetchVehicles.mockResolvedValue([
{ id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' },
]);
// Deletion promise resolution is under the test's own control, so the
// in-flight state is actually observable between the two clicks.
let resolveDelete: () => void = () => {};
mockDeleteVehicle.mockImplementation(
() => new Promise<void>((resolve) => { resolveDelete = resolve; }),
);
const { VehicleTable } = await import(
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
);
render(<VehicleTable />);
await waitFor(() => {
expect(screen.getByText('M-AB 123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Fahrzeug löschen' }));
await waitFor(() => {
expect(screen.getByText(/wird gelöscht/i)).toBeInTheDocument();
});
const confirmBtn = screen.getByRole('button', { name: 'form.deleteConfirm' });
fireEvent.click(confirmBtn);
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(mockDeleteVehicle).toHaveBeenCalledTimes(1);
});
resolveDelete();
});
it('confirm and cancel buttons of the delete dialog are disabled while the deletion is in flight', async () => {
mockFetchVehicles.mockResolvedValue([
{ id: 'v1', kennzeichen: 'M-AB 123', marke: 'VW', modell: 'Golf', fahrer: 'Hans Müller' },
]);
let resolveDelete: () => void = () => {};
mockDeleteVehicle.mockImplementation(
() => new Promise<void>((resolve) => { resolveDelete = resolve; }),
);
const { VehicleTable } = await import(
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
);
render(<VehicleTable />);
await waitFor(() => {
expect(screen.getByText('M-AB 123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Fahrzeug löschen' }));
const confirmBtn = await screen.findByRole('button', { name: 'form.deleteConfirm' });
const cancelBtn = screen.getByRole('button', { name: 'Abbrechen' });
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(confirmBtn).toBeDisabled();
expect(cancelBtn).toBeDisabled();
});
resolveDelete();
});
it('shows the load-error key when fetchVehicles fails', async () => {
mockFetchVehicles.mockRejectedValue(new Error('network down'));
const { VehicleTable } = await import(
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
);
render(<VehicleTable />);
await waitFor(() => {
expect(screen.getByText('errors.loadVehiclesFailed')).toBeInTheDocument();
});
});
});
@@ -86,11 +86,12 @@ const editInputCls =
// --- Delete confirm dialog ---
interface DeleteDialogProps {
plate: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) {
function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogProps) {
const t = useTranslations('dkvFleet');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
@@ -105,16 +106,18 @@ function DeleteDialog({ plate, onConfirm, onCancel }: DeleteDialogProps) {
<button
type="button"
onClick={onCancel}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted"
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{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"
disabled={isDeleting}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
Löschen
{t('form.deleteConfirm')}
</button>
</div>
</div>
@@ -161,7 +164,7 @@ export function VehicleTable() {
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<DkvVehicle | null>(null);
const [, setIsDeleting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Server error below table
const [tableError, setTableError] = useState<string | null>(null);
@@ -181,11 +184,13 @@ export function VehicleTable() {
const data = await fetchVehicles();
setVehicles(data);
} catch {
setTableError('Fahrzeuge konnten nicht geladen werden.');
setTableError(t('errors.loadVehiclesFailed'));
} finally {
setIsLoading(false);
}
}, []);
}, []); // `t` intentionally excluded: `load` is a dependency of the
// mount-time useEffect below, and an unstable `t` identity would turn
// that into a fetch-on-every-render loop instead of fetch-once-on-mount.
useEffect(() => {
load();
@@ -207,7 +212,7 @@ export function VehicleTable() {
const saveEdit = async (id: string) => {
if (!editState.kennzeichen.trim()) {
setEditError('Kennzeichen ist erforderlich.');
setEditError(t('errors.licensePlateRequired'));
return;
}
setIsSavingRow(true);
@@ -223,7 +228,7 @@ export function VehicleTable() {
setEditId(null);
await load();
} catch {
setEditError('Speichern fehlgeschlagen.');
setEditError(t('errors.saveVehicleFailed'));
} finally {
setIsSavingRow(false);
}
@@ -245,7 +250,7 @@ export function VehicleTable() {
const saveNew = async () => {
if (!newRow.kennzeichen.trim()) {
setNewRowError('Kennzeichen ist erforderlich.');
setNewRowError(t('errors.licensePlateRequired'));
return;
}
setIsSavingNew(true);
@@ -262,7 +267,7 @@ export function VehicleTable() {
setNewRow(EMPTY_EDIT);
await load();
} catch {
setNewRowError('Fahrzeug konnte nicht hinzugefügt werden.');
setNewRowError(t('errors.createVehicleFailed'));
} finally {
setIsSavingNew(false);
}
@@ -270,14 +275,14 @@ export function VehicleTable() {
// --- Delete ---
const confirmDelete = async () => {
if (!deleteTarget) return;
if (!deleteTarget || isDeleting) return;
setIsDeleting(true);
try {
await deleteVehicle(deleteTarget.id);
setDeleteTarget(null);
await load();
} catch {
setTableError('Fahrzeug konnte nicht gelöscht werden.');
setTableError(t('errors.deleteVehicleFailed'));
setDeleteTarget(null);
} finally {
setIsDeleting(false);
@@ -394,7 +399,7 @@ export function VehicleTable() {
<div className="flex gap-1">
<button
type="button"
aria-label="Änderungen speichern"
aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)}
disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`}
@@ -403,7 +408,7 @@ export function VehicleTable() {
</button>
<button
type="button"
aria-label="Bearbeitung abbrechen"
aria-label={t('form.cancel')}
onClick={cancelEdit}
className={iconBtnCls}
>
@@ -429,7 +434,7 @@ export function VehicleTable() {
<div className="flex gap-1">
<button
type="button"
aria-label="Fahrzeug bearbeiten"
aria-label={t('editVehicle')}
onClick={() => startEdit(v)}
className={iconBtnCls}
>
@@ -437,7 +442,7 @@ export function VehicleTable() {
</button>
<button
type="button"
aria-label="Fahrzeug löschen"
aria-label={t('deleteVehicle')}
onClick={() => setDeleteTarget(v)}
className={`${iconBtnCls} hover:text-destructive`}
>
@@ -501,7 +506,7 @@ export function VehicleTable() {
<div className="flex gap-1">
<button
type="button"
aria-label="Änderungen speichern"
aria-label={t('form.saveRow')}
onClick={saveNew}
disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`}
@@ -510,7 +515,7 @@ export function VehicleTable() {
</button>
<button
type="button"
aria-label="Bearbeitung abbrechen"
aria-label={t('form.cancel')}
onClick={cancelAdd}
className={iconBtnCls}
>
@@ -536,6 +541,7 @@ export function VehicleTable() {
{deleteTarget && (
<DeleteDialog
plate={deleteTarget.kennzeichen}
isDeleting={isDeleting}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>