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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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": "اسم الجهة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,11 +1105,67 @@ export default function ProtocolPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderBookingsTable = (list: Booking[]) => (
|
||||
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")}
|
||||
@@ -1079,7 +1197,9 @@ export default function ProtocolPage() {
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user