Add functionality to select and delete multiple bookings

Implements bulk booking deletion with API endpoint, UI elements for selection, and updated localization files.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b10c2dde-049d-45ea-9f02-7d538e07d5de
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
Replit Agent
2026-07-08 12:36:17 +00:00
parent 4c770fc49f
commit 6f47e3ee71
4 changed files with 250 additions and 17 deletions
@@ -1104,6 +1104,45 @@ router.delete(
},
);
// Bulk delete. One transaction: fetch existing rows (for audit), delete them
// all, and write one audit row per deleted booking. Ids that don't exist are
// simply skipped and reported back so the client can toast "deleted X of Y".
const bulkDeleteSchema = z.object({
ids: z.array(z.number().int().positive()).min(1).max(500),
});
router.post(
"/protocol/bookings/bulk-delete",
requireMutate,
async (req: Request, res: Response) => {
const body = parseBody(res, bulkDeleteSchema, req.body);
if (!body) return;
const ids = Array.from(new Set(body.ids));
const deletedIds = await db.transaction(async (tx) => {
const existing = await tx
.select()
.from(protocolRoomBookingsTable)
.where(inArray(protocolRoomBookingsTable.id, ids));
if (existing.length === 0) return [] as number[];
const foundIds = existing.map((r) => r.id);
await tx
.delete(protocolRoomBookingsTable)
.where(inArray(protocolRoomBookingsTable.id, foundIds));
for (const row of existing) {
await logAudit(tx, {
action: "booking.delete",
entityType: "booking",
entityId: row.id,
oldValue: row,
performedBy: req.session.userId!,
});
}
return foundIds;
});
res.json({ requested: ids.length, deleted: deletedIds.length, deletedIds });
},
);
// ---------------------------------------------------------------------------
// External meetings
// ---------------------------------------------------------------------------
+27
View File
@@ -1719,6 +1719,33 @@
"attendeeCount": "العدد المتوقع للحضور",
"publicRequest": "طلب عام",
"today": "اليوم",
"bulk": {
"selectAll": "تحديد الكل",
"selectRow": "تحديد الحجز {{number}}",
"clearSelection": "إلغاء التحديد",
"selectedCount_one": "تم تحديد حجز واحد",
"selectedCount_two": "تم تحديد حجزين",
"selectedCount_few": "تم تحديد {{count}} حجوزات",
"selectedCount_many": "تم تحديد {{count}} حجزاً",
"selectedCount_other": "تم تحديد {{count}} حجز",
"delete": "حذف",
"deleteConfirmTitle_one": "حذف حجز واحد؟",
"deleteConfirmTitle_two": "حذف حجزين؟",
"deleteConfirmTitle_few": "حذف {{count}} حجوزات؟",
"deleteConfirmTitle_many": "حذف {{count}} حجزاً؟",
"deleteConfirmTitle_other": "حذف {{count}} حجز؟",
"deleteConfirmBody_one": "سيتم حذف حجز واحد نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_two": "سيتم حذف حجزين نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_few": "سيتم حذف {{count}} حجوزات نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_many": "سيتم حذف {{count}} حجزاً نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_other": "سيتم حذف {{count}} حجز نهائياً. لا يمكن التراجع.",
"deleted_one": "تم حذف حجز واحد",
"deleted_two": "تم حذف حجزين",
"deleted_few": "تم حذف {{count}} حجوزات",
"deleted_many": "تم حذف {{count}} حجزاً",
"deleted_other": "تم حذف {{count}} حجز",
"deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي"
},
"reference": "رقم الحجز",
"department": "الإدارة",
"entity": "اسم الجهة",
+15
View File
@@ -1592,6 +1592,21 @@
"attendeeCount": "Expected attendees",
"publicRequest": "Public request",
"today": "Today",
"bulk": {
"selectAll": "Select all",
"selectRow": "Select booking {{number}}",
"clearSelection": "Clear selection",
"selectedCount_one": "1 booking selected",
"selectedCount_other": "{{count}} bookings selected",
"delete": "Delete",
"deleteConfirmTitle_one": "Delete 1 booking?",
"deleteConfirmTitle_other": "Delete {{count}} bookings?",
"deleteConfirmBody_one": "This will permanently delete 1 booking. This cannot be undone.",
"deleteConfirmBody_other": "This will permanently delete {{count}} bookings. This cannot be undone.",
"deleted_one": "1 booking deleted",
"deleted_other": "{{count}} bookings deleted",
"deletePartial": "Deleted {{deleted}} of {{total}}; the rest could not be deleted"
},
"reference": "Booking ref",
"department": "Department",
"entity": "Entity",
+169 -17
View File
@@ -611,6 +611,22 @@ export default function ProtocolPage() {
else next.add(id);
return next;
});
// Bulk selection of bookings (list view, canMutate only).
const [selectedBookings, setSelectedBookings] = useState<Set<number>>(
() => new Set(),
);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const toggleBookingSelected = (id: number) =>
setSelectedBookings((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Clear selection whenever the visible list changes (search, view, tab).
useEffect(() => {
setSelectedBookings(new Set());
}, [debouncedBookingSearch, bookingsView, tab]);
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
@@ -754,6 +770,35 @@ export default function ProtocolPage() {
},
});
const bulkDeleteMut = useMutation({
mutationFn: (ids: number[]) =>
apiJson<{ requested: number; deleted: number; deletedIds: number[] }>(
`${API}/protocol/bookings/bulk-delete`,
{ method: "POST", body: JSON.stringify({ ids }) },
),
onSuccess: (data, ids) => {
setBulkDeleteOpen(false);
setSelectedBookings(new Set());
if (data.deleted < ids.length) {
toast({
title: t("protocol.bookings.bulk.deletePartial", {
deleted: data.deleted,
total: ids.length,
}),
});
} else {
toast({
title: t("protocol.bookings.bulk.deleted", { count: data.deleted }),
});
}
invalidate("bookings");
},
onError: (e) => {
setBulkDeleteOpen(false);
onApiError(e);
},
});
const back = () => setLocation("/");
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
@@ -791,7 +836,7 @@ export default function ProtocolPage() {
const renderBookingRow = (b: Booking, index: number) => {
const expanded = expandedBookings.has(b.id);
const bookingIsToday = sameYmd(new Date(b.startsAt), now);
const detailColSpan = 8;
const detailColSpan = c?.canMutate ? 9 : 8;
return (
<>
<TableRow
@@ -806,6 +851,23 @@ export default function ProtocolPage() {
)}
data-testid={`booking-row-${b.id}`}
>
{c?.canMutate && (
<TableCell
className="w-8 text-center align-middle"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="h-4 w-4 cursor-pointer accent-sky-600 align-middle"
checked={selectedBookings.has(b.id)}
onChange={() => toggleBookingSelected(b.id)}
aria-label={t("protocol.bookings.bulk.selectRow", {
number: bookingNumber(b),
})}
data-testid={`booking-select-${b.id}`}
/>
</TableCell>
)}
<TableCell className="w-10 text-center align-middle">
<span className="mx-auto flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
{index}
@@ -1043,12 +1105,68 @@ export default function ProtocolPage() {
);
};
const renderBookingsTable = (list: Booking[]) => (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-100/80 hover:bg-slate-100/80">
<TableHead className="w-10 text-center">#</TableHead>
const renderBookingsTable = (list: Booking[]) => {
const selectedInList = list.filter((b) => selectedBookings.has(b.id));
const allSelected =
list.length > 0 && selectedInList.length === list.length;
const someSelected = selectedInList.length > 0 && !allSelected;
return (
<div className="space-y-2">
{c?.canMutate && selectedInList.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-sky-200 bg-sky-50 px-3 py-2">
<span className="text-sm font-medium text-sky-800">
{t("protocol.bookings.bulk.selectedCount", {
count: selectedInList.length,
})}
</span>
<div className="ms-auto flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedBookings(new Set())}
>
{t("protocol.bookings.bulk.clearSelection")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setBulkDeleteOpen(true)}
disabled={bulkDeleteMut.isPending}
data-testid="booking-bulk-delete"
>
<Trash2 size={14} className="me-1" />
{t("protocol.bookings.bulk.delete")}
</Button>
</div>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-100/80 hover:bg-slate-100/80">
{c?.canMutate && (
<TableHead className="w-8 text-center">
<input
type="checkbox"
className="h-4 w-4 cursor-pointer accent-sky-600 align-middle"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() =>
setSelectedBookings(
allSelected
? new Set()
: new Set(list.map((b) => b.id)),
)
}
aria-label={t("protocol.bookings.bulk.selectAll")}
title={t("protocol.bookings.bulk.selectAll")}
data-testid="booking-select-all"
/>
</TableHead>
)}
<TableHead className="w-10 text-center">#</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.number")}
</TableHead>
@@ -1070,16 +1188,18 @@ export default function ProtocolPage() {
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((b, i) => (
<Fragment key={b.id}>{renderBookingRow(b, i + 1)}</Fragment>
))}
</TableBody>
</Table>
</div>
);
</TableRow>
</TableHeader>
<TableBody>
{list.map((b, i) => (
<Fragment key={b.id}>{renderBookingRow(b, i + 1)}</Fragment>
))}
</TableBody>
</Table>
</div>
</div>
);
};
return (
<div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}>
@@ -1821,6 +1941,38 @@ export default function ProtocolPage() {
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={bulkDeleteOpen}
onOpenChange={(o) => !o && setBulkDeleteOpen(false)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("protocol.bookings.bulk.deleteConfirmTitle", {
count: selectedBookings.size,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.bookings.bulk.deleteConfirmBody", {
count: selectedBookings.size,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() =>
selectedBookings.size > 0 &&
bulkDeleteMut.mutate(Array.from(selectedBookings))
}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AppDock currentSlug="protocol" />
</div>
);