Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/treeckle/bookings/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,28 @@ def update_booking_status(

return updated_bookings, id_to_previous_booking_status_mapping

@transaction.atomic
def bulk_update_booking_status(
booking_ids: list[int], action: BookingStatusAction, user: User
) -> tuple[list[Booking], dict[int, BookingStatus]]:

bookings_to_update = get_bookings(id__in=booking_ids)

all_updated_bookings_dict = {}
master_status_mapping = {}

for booking in bookings_to_update:
# reuse single update_booking_status
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

updated_bookings, status_mapping = update_booking_status(
booking=booking, action=action, user=user
)

for updated_booking in updated_bookings:
all_updated_bookings_dict[updated_booking.id] = updated_booking

master_status_mapping.update(status_mapping)

return list(all_updated_bookings_dict.values()), master_status_mapping

def delete_bookings(
booking_ids_to_be_deleted: Iterable[int], organization: Organization
Expand Down
7 changes: 7 additions & 0 deletions backend/treeckle/bookings/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,10 @@ class Meta:

class PatchSingleBookingSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=BookingStatusAction.choices)

class PatchBulkBookingSerializer(serializers.Serializer):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

booking_ids = serializers.ListField(
child=serializers.IntegerField(),
allow_empty=False
)
action = serializers.ChoiceField(choices=BookingStatusAction.choices)
4 changes: 3 additions & 1 deletion backend/treeckle/bookings/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
PendingBookingCountView,
BookingsView,
SingleBookingView,
BulkBookingView,
)
from comments.views import BookingCommentsView

urlpatterns = [
path("", BookingsView.as_view(), name="bookings"),
path("totalcount", TotalBookingCountView.as_view(), name="total_count"),
path("pendingcount", PendingBookingCountView.as_view(), name="pending_count"),
path("<int:booking_id>", SingleBookingView.as_view(), name="single_booking"),
path("bulk", BulkBookingView.as_view(), name="bulk-bookings"),
path("<int:booking_id>", SingleBookingView.as_view(), name="single-booking"),
path(
"<int:booking_id>/comments",
BookingCommentsView.as_view(),
Expand Down
36 changes: 36 additions & 0 deletions backend/treeckle/bookings/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
GetBookingSerializer,
PostBookingSerializer,
PatchSingleBookingSerializer,
PatchBulkBookingSerializer,
)
from .models import Booking, BookingStatus
from .middlewares import check_requester_is_booker_or_admin
Expand All @@ -30,6 +31,7 @@
create_bookings,
DateTimeInterval,
update_booking_status,
bulk_update_booking_status,
)
from .middlewares import check_requester_booking_same_organization

Expand Down Expand Up @@ -366,3 +368,37 @@ def delete(self, request, requester: User, booking: Booking):
booking.delete()

return Response(data, status=status.HTTP_200_OK)

class BulkBookingView(APIView):
"""
Bulk booking management endpoint.

PATCH: Update status for multiple bookings
- Actions: APPROVE, REJECT
- Runs in a single atomic transaction
- Returns updated booking(s) information
"""

@check_access(Role.ADMIN)
def patch(self, request, requester: User):
serializer = PatchBulkBookingSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

booking_ids = serializer.validated_data.get("booking_ids")
action = serializer.validated_data.get("action")

(
updated_bookings,
master_status_mapping,
) = bulk_update_booking_status(
booking_ids=booking_ids, action=action, user=requester
)

send_updated_booking_emails(
Copy link
Copy Markdown
Contributor

@zachchong zachchong Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Shum-ster help to test this after deployed to beta, thanks. LGTM.

bookings=updated_bookings,
id_to_previous_booking_status_mapping=master_status_mapping,
)

data = [booking_to_json(booking) for booking in updated_bookings]

return Response(data, status=status.HTTP_200_OK)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@use "../../base";

.bookingBaseTable {
@include base.table;
cursor: pointer;

div {
box-shadow: none;
}

.extraContentContainer {
width: 100%;
}

.detailsContainer {
:global(.row) {
padding: 0.5rem 0;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { useCallback, useState, useRef, useEffect } from "react";
import { AutoResizer, Column, ColumnShape, RowKey } from "react-base-table";
import { Segment, Checkbox } from "semantic-ui-react";

import {
ACTION,
CHECKBOX,
CREATED_AT_STRING,
EVENT_TIME_RANGE,
ID,
START_DATE_TIME_STRING,
START_TIME_MINS,
STATUS,
} from "../../constants";
import { useGetSingleBooking } from "../../custom-hooks/api/bookings-api";
import { useAppDispatch } from "../../redux/hooks";
import { updateBookingsAction } from "../../redux/slices/bookings-slice";
import { BookingData } from "../../types/bookings";
import BookingDetailsView from "../booking-details-view";
import BookingStatusButton from "../booking-status-button";
import Table, { TableProps } from "../table";
import styles from "./admin-booking-base-table.module.scss";

export type BookingViewProps = BookingData & {
[START_TIME_MINS]: number;
[START_DATE_TIME_STRING]: string;
[EVENT_TIME_RANGE]: string;
[CREATED_AT_STRING]: string;
booking?: BookingData;
children: { [ID]: string; booking: BookingData }[];
};

type Props = Partial<TableProps<BookingViewProps>> & {
adminView?: boolean;
defaultStatusColumnWidth?: number;
defaultActionColumnWidth?: number;
selectedBookingIds?: Set<number>;
onSelectionChange?: (selectedIds: Set<number>) => void;
};

const RowRenderer: TableProps<BookingViewProps>["rowRenderer"] = ({
rowData: { booking },
cells,
columns,
}: {
rowData: BookingViewProps;
cells: React.ReactNode[];
columns: ColumnShape<BookingViewProps>;
}) =>
// Only render details if there are booking details
// and the column is not the frozen column
booking && columns.length > 1 ? (
<Segment className={styles.extraContentContainer} basic>
<BookingDetailsView
className={styles.detailsContainer}
booking={booking}
/>
</Segment>
) : (
cells
);

function BookingBaseTable({
adminView = false,
defaultStatusColumnWidth = 100,
defaultActionColumnWidth = 100,
selectedBookingIds = new Set(),
onSelectionChange,

children,
...props
}: Props) {
const { getSingleBooking } = useGetSingleBooking();
const dispatch = useAppDispatch();

const StatusButtonRenderer: ColumnShape<BookingViewProps>["cellRenderer"] =
useCallback(
({ rowData: { status, id } }: { rowData: BookingViewProps }) =>
status &&
id !== undefined && (
<BookingStatusButton
bookingId={id}
status={status}
adminView={adminView}
/>
),
[adminView],
);

const selectedIdsRef = useRef(selectedBookingIds);

useEffect(() => {
selectedIdsRef.current = selectedBookingIds;
}, [selectedBookingIds]);

const CheckboxRenderer: ColumnShape<BookingViewProps>["cellRenderer"] =
useCallback(
({ rowData: { id } }: { rowData: BookingViewProps }) => {
if (id === undefined) return null;
const isChecked = selectedBookingIds.has(id);
return (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isChecked}
onChange={() => {
const currentSet = selectedIdsRef.current;
const newSelected = new Set(currentSet);
if (currentSet.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
if (onSelectionChange) {
onSelectionChange(newSelected);
}
}}
/>
</div>
Comment on lines +102 to +118
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkbox is wrapped in a div with onClick stopPropagation, which prevents the row click event from firing when the checkbox is clicked. However, this only stops mouse click propagation. Keyboard users using Space or Enter to toggle the checkbox may still trigger the row expansion. Consider also adding onKeyDown handler to stop keyboard event propagation, or ensure the Checkbox component itself handles keyboard events appropriately.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Shum-ster verify this

);
},
[selectedBookingIds, onSelectionChange],
);
Comment on lines +90 to +122
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CheckboxRenderer uses a ref pattern (selectedIdsRef) to access the current selection state, but this creates a potential issue. The ref is updated in useEffect but the CheckboxRenderer callback is only recreated when selectedBookingIds or onSelectionChange changes. This means if selectedBookingIds is updated externally (e.g., by the footer's "Select all" or "Deselect all"), the CheckboxRenderer will still use the ref which points to the new Set, making the useCallback dependency array misleading. Consider removing the ref pattern and directly using selectedBookingIds in the callback, or ensure the dependencies accurately reflect what the callback uses.

Copilot uses AI. Check for mistakes.

const [expandedRowKeys, setExpandedRowKeys] = useState<RowKey[]>([]);
const onRowExpand: TableProps<BookingViewProps>["onRowExpand"] = ({
expanded,
rowData: { id, formResponseData },
}) => {
if (!expanded || formResponseData) {
return;
}
getSingleBooking(id)
.then((booking) => {
if (booking) {
dispatch(updateBookingsAction({ bookings: [booking] }));
} else {
console.error("Failed to update booking details for booking ID", id);
}
})
.catch((error) => console.error(error));
};

return (
<Segment className={styles.bookingBaseTable}>
<AutoResizer>
{({ width, height }) => (
<Table<BookingViewProps>
width={width}
height={height}
ignoreFunctionInColumnCompare={false}
rowRenderer={RowRenderer}
estimatedRowHeight={50}
fixed
expandColumnKey={ACTION}
onRowExpand={onRowExpand}
expandedRowKeys={expandedRowKeys}
rowEventHandlers={{
onClick: ({ rowData, rowKey, rowIndex }) => {
if (!rowData.children || rowData.children.length === 0) return;
if (expandedRowKeys.includes(rowKey))
setExpandedRowKeys(
expandedRowKeys.filter((x) => x !== rowKey),
);
else {
setExpandedRowKeys([...expandedRowKeys, rowKey]);
onRowExpand({ expanded: true, rowData, rowIndex, rowKey });
}
},
}}
{...props}
>
<Column<BookingViewProps>
key={CHECKBOX}
title=""
width={50}
align="center"
cellRenderer={CheckboxRenderer}
resizable={false}
frozen="left"
/>
Comment on lines +172 to +180
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkbox column lacks an accessible label or aria-label. Screen readers will not be able to announce what the checkbox is for. Consider adding an aria-label to the Column component or to the Checkbox itself to indicate its purpose (e.g., "Select booking for bulk action").

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's intuitive, can ignore

{children}
<Column<BookingViewProps>
key={STATUS}
title="Status"
width={defaultStatusColumnWidth}
sortable
align="center"
cellRenderer={StatusButtonRenderer}
resizable
/>
<Column<BookingViewProps>
key={ACTION}
title=""
width={defaultActionColumnWidth}
align="center"
frozen="right"
/>
</Table>
)}
</AutoResizer>
</Segment>
);
}

export default BookingBaseTable;
2 changes: 2 additions & 0 deletions frontend/src/components/admin-booking-base-table/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { BookingViewProps } from "./admin-booking-base-table";
export { default } from "./admin-booking-base-table";
Loading