Skip to content

Commit 4c16ff7

Browse files
mighteaclaude
andcommitted
feat: bulk-select and delete multiple maintenance entries
Adds a selection mode toggle to the maintenance history list. When active, each group row shows a checkbox and the sticky header swaps for an action bar with a counter, Abbrechen, and Löschen. Confirming the deletion fans the existing single-record delete endpoint over every record id in the selected groups (parent + bundled children). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dc01bdc commit 4c16ff7

2 files changed

Lines changed: 231 additions & 32 deletions

File tree

app/components/maintenance-list.tsx

Lines changed: 157 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
Activity,
2323
Plus,
2424
X,
25+
Check,
26+
CheckSquare,
27+
Trash2,
2528
} from "lucide-react";
2629
import { useState } from "react";
2730
import type { MaintenanceRecord, MaintenanceType, Location, FluidType, BatteryType } from "~/types/db";
@@ -46,6 +49,12 @@ interface MaintenanceListProps {
4649
userLocations?: Location[];
4750
onEdit: (record: MaintenanceRecord) => void;
4851
onAdd?: () => void;
52+
/**
53+
* When provided, enables the bulk-select UI. The parent receives every
54+
* underlying record id (parent + bundled children) for the selected groups
55+
* and is responsible for confirming and performing the deletion.
56+
*/
57+
onBulkDelete?: (recordIds: number[]) => void;
4958
}
5059

5160
interface GroupedMaintenanceRecord {
@@ -199,9 +208,13 @@ function getCollapsedMetric(group: GroupedMaintenanceRecord, currencyCode?: stri
199208
return null;
200209
}
201210

202-
export function MaintenanceList({ records, currencyCode, userLocations, onEdit, onAdd }: MaintenanceListProps) {
211+
export function MaintenanceList({ records, currencyCode, userLocations, onEdit, onAdd, onBulkDelete }: MaintenanceListProps) {
203212
const [expandedGroupId, setExpandedGroupId] = useState<string | null>(null);
204213
const [filter, setFilter] = useState<"all" | "maintenance" | "fuel">("all");
214+
const [selectionMode, setSelectionMode] = useState(false);
215+
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set());
216+
217+
const bulkSelectEnabled = typeof onBulkDelete === "function";
205218

206219
const dateFormatter = new Intl.DateTimeFormat("de-CH", {
207220
dateStyle: "medium",
@@ -232,35 +245,123 @@ export function MaintenanceList({ records, currencyCode, userLocations, onEdit,
232245
setExpandedGroupId(expandedGroupId === id ? null : id);
233246
};
234247

248+
const toggleSelectGroup = (id: string) => {
249+
setSelectedGroupIds((prev) => {
250+
const next = new Set(prev);
251+
if (next.has(id)) {
252+
next.delete(id);
253+
} else {
254+
next.add(id);
255+
}
256+
return next;
257+
});
258+
};
259+
260+
const exitSelectionMode = () => {
261+
setSelectionMode(false);
262+
setSelectedGroupIds(new Set());
263+
};
264+
265+
const enterSelectionMode = () => {
266+
setSelectionMode(true);
267+
setExpandedGroupId(null);
268+
};
269+
270+
const handleBulkDelete = () => {
271+
if (!onBulkDelete) return;
272+
const ids: number[] = [];
273+
for (const group of groupedRecords) {
274+
if (!selectedGroupIds.has(group.id)) continue;
275+
for (const record of group.originalRecords) {
276+
ids.push(record.id);
277+
}
278+
}
279+
if (ids.length === 0) return;
280+
onBulkDelete(ids);
281+
exitSelectionMode();
282+
};
283+
284+
const selectedCount = selectedGroupIds.size;
285+
235286
return (
236287
<div className="space-y-6">
237288
<div
238-
className="sticky z-20 -mx-4 flex items-center justify-between gap-3 border-b border-gray-100 bg-white px-4 pb-3 pt-3 dark:border-navy-700 dark:bg-navy-800"
289+
className={clsx(
290+
"sticky z-20 -mx-4 flex items-center justify-between gap-3 border-b px-4 pb-3 pt-3",
291+
selectionMode
292+
? "border-primary/30 bg-primary/10 dark:bg-primary/15"
293+
: "border-gray-100 bg-white dark:border-navy-700 dark:bg-navy-800",
294+
)}
239295
style={{ top: "var(--app-header-h, 4rem)" }}
240296
>
241-
<div className="flex items-center gap-2 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
242-
<FilterChip
243-
label="Alle"
244-
icon={Layers}
245-
active={filter === "all"}
246-
onClick={() => setFilter("all")}
247-
/>
248-
<FilterChip
249-
label="Wartung"
250-
icon={Wrench}
251-
active={filter === "maintenance"}
252-
onClick={() => setFilter("maintenance")}
253-
/>
254-
<FilterChip
255-
label="Tanken"
256-
icon={Fuel}
257-
active={filter === "fuel"}
258-
onClick={() => setFilter("fuel")}
259-
/>
260-
</div>
261-
<div className="shrink-0 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-base-content/45 dark:text-navy-500">
262-
{groupedRecords.length} {groupedRecords.length === 1 ? 'Eintrag' : 'Einträge'}
263-
</div>
297+
{selectionMode ? (
298+
<>
299+
<div className="flex items-center gap-2 text-sm font-semibold text-primary dark:text-primary-light">
300+
<CheckSquare className="h-4 w-4" aria-hidden="true" />
301+
<span aria-live="polite">
302+
{selectedCount} {selectedCount === 1 ? "ausgewählt" : "ausgewählt"}
303+
</span>
304+
</div>
305+
<div className="flex items-center gap-2">
306+
<button
307+
type="button"
308+
onClick={exitSelectionMode}
309+
className="inline-flex items-center gap-1 rounded-sm px-2.5 py-1.5 text-xs font-semibold text-base-content/70 transition-colors hover:bg-base-200 dark:text-navy-300 dark:hover:bg-navy-700"
310+
>
311+
<X className="h-3.5 w-3.5" aria-hidden="true" />
312+
Abbrechen
313+
</button>
314+
<button
315+
type="button"
316+
onClick={handleBulkDelete}
317+
disabled={selectedCount === 0}
318+
className="inline-flex items-center gap-1 rounded-sm bg-error px-2.5 py-1.5 text-xs font-semibold text-error-content shadow-sm transition-colors hover:bg-error/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-error/40 disabled:cursor-not-allowed disabled:opacity-50"
319+
>
320+
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
321+
Löschen
322+
</button>
323+
</div>
324+
</>
325+
) : (
326+
<>
327+
<div className="flex items-center gap-2 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
328+
<FilterChip
329+
label="Alle"
330+
icon={Layers}
331+
active={filter === "all"}
332+
onClick={() => setFilter("all")}
333+
/>
334+
<FilterChip
335+
label="Wartung"
336+
icon={Wrench}
337+
active={filter === "maintenance"}
338+
onClick={() => setFilter("maintenance")}
339+
/>
340+
<FilterChip
341+
label="Tanken"
342+
icon={Fuel}
343+
active={filter === "fuel"}
344+
onClick={() => setFilter("fuel")}
345+
/>
346+
</div>
347+
<div className="flex shrink-0 items-center gap-3">
348+
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-base-content/45 dark:text-navy-500">
349+
{groupedRecords.length} {groupedRecords.length === 1 ? 'Eintrag' : 'Einträge'}
350+
</span>
351+
{bulkSelectEnabled && groupedRecords.length > 0 && (
352+
<button
353+
type="button"
354+
onClick={enterSelectionMode}
355+
className="inline-flex items-center gap-1 rounded-sm border border-base-300 px-2 py-1 text-[11px] font-semibold text-base-content/70 transition-colors hover:bg-base-200 dark:border-navy-700 dark:text-navy-300 dark:hover:bg-navy-700"
356+
aria-label="Mehrere Einträge auswählen"
357+
>
358+
<CheckSquare className="h-3.5 w-3.5" aria-hidden="true" />
359+
Auswählen
360+
</button>
361+
)}
362+
</div>
363+
</>
364+
)}
264365
</div>
265366

266367
{groupedRecords.length === 0 ? (
@@ -316,7 +417,8 @@ export function MaintenanceList({ records, currencyCode, userLocations, onEdit,
316417
<ul className="space-y-2">
317418
{recordsByYear.get(year)!.map((group) => {
318419
const Icon = getIconForType(group.type);
319-
const isExpanded = expandedGroupId === group.id;
420+
const isExpanded = !selectionMode && expandedGroupId === group.id;
421+
const isSelected = selectedGroupIds.has(group.id);
320422
const tone = TYPE_TONE[group.type] ?? TYPE_TONE.general;
321423
const typeLabel = maintenanceTypeLabels[group.type] || group.type;
322424

@@ -339,14 +441,36 @@ export function MaintenanceList({ records, currencyCode, userLocations, onEdit,
339441
const showTypeTag = !summaryRepeatsType;
340442

341443
return (
342-
<li key={group.id} className="rounded-sm transition-colors hover:bg-base-200/50 dark:hover:bg-navy-700/30">
444+
<li
445+
key={group.id}
446+
className={clsx(
447+
"rounded-sm transition-colors",
448+
isSelected
449+
? "bg-primary/10 dark:bg-primary/15"
450+
: "hover:bg-base-200/50 dark:hover:bg-navy-700/30",
451+
)}
452+
>
343453
<button
344454
type="button"
345-
onClick={() => toggleExpand(group.id)}
346-
aria-expanded={isExpanded}
347-
aria-controls={`maintenance-details-${group.id}`}
455+
onClick={() => (selectionMode ? toggleSelectGroup(group.id) : toggleExpand(group.id))}
456+
aria-expanded={selectionMode ? undefined : isExpanded}
457+
aria-pressed={selectionMode ? isSelected : undefined}
458+
aria-controls={selectionMode ? undefined : `maintenance-details-${group.id}`}
348459
className="group flex w-full cursor-pointer items-start gap-3 py-2.5 pl-0 text-left"
349460
>
461+
{selectionMode && (
462+
<div
463+
className={clsx(
464+
"mt-1.5 grid h-5 w-5 shrink-0 place-items-center rounded-sm border transition-colors",
465+
isSelected
466+
? "border-primary bg-primary text-primary-content"
467+
: "border-base-300 bg-base-100 dark:border-navy-600 dark:bg-navy-900",
468+
)}
469+
aria-hidden="true"
470+
>
471+
{isSelected && <Check className="h-3.5 w-3.5" strokeWidth={3} />}
472+
</div>
473+
)}
350474
<div className={clsx(
351475
"mt-0.5 grid h-10 w-10 place-items-center rounded-sm shrink-0 transition-transform group-hover:scale-105",
352476
tone.bg,
@@ -366,7 +490,9 @@ export function MaintenanceList({ records, currencyCode, userLocations, onEdit,
366490
{formatNumber(group.odo)}
367491
</span>
368492
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-base-content/55">km</span>
369-
<ChevronDown className={clsx("h-4 w-4 self-center text-base-content/45 transition-transform dark:text-navy-400", isExpanded && "rotate-180")} aria-hidden="true" />
493+
{!selectionMode && (
494+
<ChevronDown className={clsx("h-4 w-4 self-center text-base-content/45 transition-transform dark:text-navy-400", isExpanded && "rotate-180")} aria-hidden="true" />
495+
)}
370496
</div>
371497
</div>
372498

app/routes/motorcycle.detail.tsx

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,34 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
451451
return data({ success: true, intent: "deleteMaintenance" });
452452
}
453453

454+
if (intent === "deleteMaintenanceBulk") {
455+
const motorcycleId = Number(formData.get("motorcycleId"));
456+
if (!Number.isFinite(motorcycleId)) {
457+
throw new Response("Ungültige Fahrzeug-ID", { status: 400 });
458+
}
459+
460+
const rawIds = formData.getAll("maintenanceIds[]");
461+
const ids = rawIds
462+
.map((v) => Number(v))
463+
.filter((n) => Number.isFinite(n));
464+
465+
if (ids.length === 0) {
466+
return data({ success: false, intent: "deleteMaintenanceBulk", error: "Keine Einträge ausgewählt" }, { status: 400 });
467+
}
468+
469+
const results = await Promise.all(
470+
ids.map((id) => deleteMaintenanceRecord(token, id, motorcycleId)),
471+
);
472+
const succeeded = results.filter(Boolean).length;
473+
const failed = results.length - succeeded;
474+
475+
if (failed > 0 && succeeded === 0) {
476+
return data({ success: false, intent: "deleteMaintenanceBulk", error: `${failed} Einträge konnten nicht gelöscht werden` }, { status: 500 });
477+
}
478+
479+
return data({ success: true, intent: "deleteMaintenanceBulk", count: succeeded, failed });
480+
}
481+
454482
if (intent === "createPreviousOwner" || intent === "updatePreviousOwner") {
455483
const motorcycleId = Number(formData.get("motorcycleId"));
456484
if (!Number.isFinite(motorcycleId)) {
@@ -592,6 +620,8 @@ export default function MotorcycleDetail({ loaderData }: Route.ComponentProps) {
592620
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
593621
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
594622
const [deleteMaintenanceConfirmationOpen, setDeleteMaintenanceConfirmationOpen] = useState(false);
623+
const [bulkDeleteIds, setBulkDeleteIds] = useState<number[]>([]);
624+
const [bulkDeleteConfirmationOpen, setBulkDeleteConfirmationOpen] = useState(false);
595625
const [maintenanceDialogOpen, setMaintenanceDialogOpen] = useState(false);
596626
const [selectedMaintenance, setSelectedMaintenance] = useState<(typeof maintenanceHistory)[number] | null>(null);
597627
const [issueDialogOpen, setIssueDialogOpen] = useState(false);
@@ -600,7 +630,7 @@ export default function MotorcycleDetail({ loaderData }: Route.ComponentProps) {
600630
const [selectedPreviousOwner, setSelectedPreviousOwner] = useState<(typeof previousOwnersList)[number] | null>(null);
601631
const [deletePreviousOwnerConfirmationOpen, setDeletePreviousOwnerConfirmationOpen] = useState(false);
602632
const revalidator = useRevalidator();
603-
const actionData = useActionData<{ success?: boolean; error?: string; errors?: Record<string, string>; intent?: string; count?: number }>();
633+
const actionData = useActionData<{ success?: boolean; error?: string; errors?: Record<string, string>; intent?: string; count?: number; failed?: number }>();
604634
const submit = useSubmit();
605635
const location = useLocation();
606636
const params = useParams<{ slug?: string; id?: string }>();
@@ -649,6 +679,18 @@ export default function MotorcycleDetail({ loaderData }: Route.ComponentProps) {
649679
case "deleteMaintenance":
650680
toast.success("Eintrag gelöscht");
651681
break;
682+
case "deleteMaintenanceBulk":
683+
if (actionData.failed && actionData.failed > 0) {
684+
toast.success(
685+
`${actionData.count} Einträge gelöscht`,
686+
`${actionData.failed} konnten nicht gelöscht werden.`,
687+
);
688+
} else {
689+
toast.success(
690+
`${actionData.count} ${actionData.count === 1 ? "Eintrag" : "Einträge"} gelöscht`,
691+
);
692+
}
693+
break;
652694
case "createPreviousOwner":
653695
toast.success("Vorbesitzer erstellt");
654696
break;
@@ -766,6 +808,11 @@ export default function MotorcycleDetail({ loaderData }: Route.ComponentProps) {
766808
setSelectedMaintenance(null);
767809
setMaintenanceDialogOpen(true);
768810
}}
811+
onBulkDelete={(ids) => {
812+
if (ids.length === 0) return;
813+
setBulkDeleteIds(ids);
814+
setBulkDeleteConfirmationOpen(true);
815+
}}
769816
/>
770817
</div>
771818
</Card>
@@ -936,6 +983,32 @@ export default function MotorcycleDetail({ loaderData }: Route.ComponentProps) {
936983
confirmLabel="Löschen"
937984
confirmDisabled={!selectedMaintenance}
938985
/>
986+
987+
<DeleteConfirmationDialog
988+
isOpen={bulkDeleteConfirmationOpen}
989+
onCancel={() => {
990+
setBulkDeleteConfirmationOpen(false);
991+
setBulkDeleteIds([]);
992+
}}
993+
onConfirm={() => {
994+
if (bulkDeleteIds.length > 0) {
995+
trackEvent("maintenance_delete_bulk", { count: bulkDeleteIds.length });
996+
const formData = new FormData();
997+
formData.append("intent", "deleteMaintenanceBulk");
998+
formData.append("motorcycleId", motorcycle.id.toString());
999+
for (const id of bulkDeleteIds) {
1000+
formData.append("maintenanceIds[]", id.toString());
1001+
}
1002+
submit(formData, { method: "post" });
1003+
}
1004+
setBulkDeleteConfirmationOpen(false);
1005+
setBulkDeleteIds([]);
1006+
}}
1007+
title={`${bulkDeleteIds.length} ${bulkDeleteIds.length === 1 ? "Eintrag" : "Einträge"} löschen`}
1008+
description="Möchtest du die ausgewählten Einträge wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
1009+
confirmLabel="Löschen"
1010+
confirmDisabled={bulkDeleteIds.length === 0}
1011+
/>
9391012
<IssueDialog
9401013
isOpen={issueDialogOpen}
9411014
onClose={closeIssueDialog}

0 commit comments

Comments
 (0)