Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
53e05ba
feat: booking approval for reservation requests
Aug 31, 2026
ff1e17e
fix: self-service submit label says request when approval is required
Sep 1, 2026
051b963
fix(bookings): make approval atomic, guarded, and correctly documented
Sep 2, 2026
ef9faec
fix(bookings): tell a lost race apart from a real failure
Sep 2, 2026
f5cfcfe
Merge origin/feat/booking-approval-flow (PR #2980) into main
Oct 20, 2026
c2f5b33
feat(bookings): managers approve requests, pending approval list filter
Nov 2, 2026
227cd1c
Merge origin/main into feat/booking-approval-revive
Nov 2, 2026
bea8626
fix(bookings): request wording on the reserve toast and settings card
Nov 2, 2026
074d6f1
fix(bookings): drop a stale Pending approval filter when approval is off
Nov 2, 2026
003eb43
feat(bookings): make pending requests visible everywhere staff look
Nov 2, 2026
09a7c1c
Merge remote-tracking branch 'origin/main' into feat/booking-approval…
Nov 2, 2026
ee1f987
fix(bookings): sweep pre-switch reservations by id, word phone reserv…
Nov 2, 2026
9093343
fix(bookings): review round two on booking approval
Nov 3, 2026
ea82dad
fix(bookings): rewrite the saved filter cookie when dropping a stale …
Nov 3, 2026
80ad512
fix(bookings): decide the first partial scan on the locked row, not t…
Nov 3, 2026
f8e1dca
fix(bookings): start from page 1 when a stale pending filter is dropped
Nov 3, 2026
cb01f30
feat(bookings): an approver's check-out approves the pending request
Nov 3, 2026
224bfde
feat(bookings): booking approval becomes a switch per requesting role
Nov 3, 2026
5f5b2e8
Merge origin/main into feat/booking-approval-revive
Nov 3, 2026
b84f5d7
Merge remote-tracking branch 'origin/main' into feat/booking-approval…
DonKoko Nov 4, 2026
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
106 changes: 98 additions & 8 deletions apps/companion/app/(tabs)/bookings/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,15 +751,22 @@ function BookingDetailScreen({ id }: { id: string }) {
startQuantityPicker();
};

// A reservation that will wait for approval is a request, and the prompt,
// the button and the success message must say so; otherwise the pending
// state that follows reads as a failure.
const reservationIsRequest = booking?.reservationNeedsApproval === true;

const handleReserve = () => {
if (!booking || !currentOrg) return;
Alert.alert(
"Reserve Booking",
`Reserve "${booking.name}"? This confirms the booking and checks for conflicts.`,
reservationIsRequest ? "Request Reservation" : "Reserve Booking",
reservationIsRequest
? `Request "${booking.name}"? The items are held while an administrator or manager reviews the request. You will be notified by email.`
: `Reserve "${booking.name}"? This confirms the booking and checks for conflicts.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Reserve",
text: reservationIsRequest ? "Request" : "Reserve",
onPress: async () => {
setIsActioning(true);
const { error: err } = await api.reserveBooking(
Expand All @@ -775,9 +782,13 @@ function BookingDetailScreen({ id }: { id: string }) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
Alert.alert("Reserved", `"${booking.name}" is now reserved.`, [
{ text: "OK", onPress: () => fetchBooking() },
]);
Alert.alert(
reservationIsRequest ? "Request sent" : "Reserved",
reservationIsRequest
? `"${booking.name}" is waiting for approval. You will receive an email once it has been reviewed.`
: `"${booking.name}" is now reserved.`,
[{ text: "OK", onPress: () => fetchBooking() }]
);
},
},
]
Expand Down Expand Up @@ -835,6 +846,38 @@ function BookingDetailScreen({ id }: { id: string }) {
// Each is permission/status-gated server-side AND only offered when the
// detail endpoint's `bookingActions` flag says this role/status may use it.

// Approve a pending reservation request. Offered only when the detail
// endpoint says this caller may decide it (`canApprove`), which is the same
// `booking:approve` answer the web button and the endpoint enforce.
const handleApprove = () => {
if (!booking || !currentOrg) return;
Alert.alert(
"Approve booking request",
`Approve "${booking.name}"? The custodian will be notified by email and the booking can then be checked out.`,
[
{ text: "Not now", style: "cancel" },
{
text: "Approve",
onPress: async () => {
setIsActioning(true);
const { error: err } = await api.approveBooking(
currentOrg.id,
booking.id
);
setIsActioning(false);
if (err) {
Alert.alert("Couldn't approve", err);
return;
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
markBookingsListDirty();
fetchBooking();
},
},
]
);
};

const handleCancel = () => {
if (!booking || !currentOrg) return;
Alert.alert(
Expand Down Expand Up @@ -1836,6 +1879,26 @@ function BookingDetailScreen({ id }: { id: string }) {
{formatStatus(booking.status)}
</Text>
</View>
{booking.isPendingApproval ? (
// Approval pill: web parity for the derived pending state.
// The server also refuses checkout while pending and sends
// `canCheckout: false`, so this is display-only.
<View
style={[
styles.statusBadgeLg,
{ backgroundColor: colors.warningBg },
]}
>
<Text
style={[
styles.statusTextLg,
{ color: colors.warningText },
]}
>
Pending approval
</Text>
</View>
) : null}
</View>

{booking.description ? (
Expand Down Expand Up @@ -2076,7 +2139,11 @@ function BookingDetailScreen({ id }: { id: string }) {
// the disabled state without setting it by hand.
disabled={!!reserveBlockedReason}
onPress={handleReserve}
accessibilityLabel="Reserve this booking"
accessibilityLabel={
reservationIsRequest
? "Request a reservation for this booking"
: "Reserve this booking"
}
accessibilityHint={reserveBlockedReason ?? undefined}
accessibilityRole="button"
>
Expand All @@ -2085,7 +2152,9 @@ function BookingDetailScreen({ id }: { id: string }) {
size={18}
color={colors.primaryForeground}
/>
<Text style={styles.actionButtonText}>Reserve</Text>
<Text style={styles.actionButtonText}>
{reservationIsRequest ? "Request reservation" : "Reserve"}
</Text>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</TouchableOpacity>
)}
</View>
Expand Down Expand Up @@ -2202,6 +2271,27 @@ function BookingDetailScreen({ id }: { id: string }) {
</TouchableOpacity>
)}

{/* A pending reservation request offers Approve in place of the
check-out paths: the server refuses check-out until it is
approved, and `canApprove` is the server's own answer on who
may decide it. */}
{booking.canApprove === true && (
<TouchableOpacity
style={styles.actionButton}
onPress={handleApprove}
disabled={isActioning}
accessibilityLabel="Approve this booking request"
accessibilityRole="button"
>
<Ionicons
name="checkmark-circle-outline"
size={18}
color={colors.primaryForeground}
/>
<Text style={styles.actionButtonText}>Approve request</Text>
</TouchableOpacity>
)}

{/* Full check-out is RESERVED-only (web parity); the loader's
canCheckout reflects that. It is hidden when the workspace
requires explicit check-out for this role, which leaves
Expand Down
13 changes: 13 additions & 0 deletions apps/companion/lib/api/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,19 @@ export const bookingsApi = {
}
),

/**
* Approve a pending reservation request. The booking stays RESERVED and
* becomes checkout-able; who may approve is enforced server-side.
*/
approveBooking: (orgId: string, bookingId: string) =>
apiFetch<BookingMutationResponse>(
`/api/mobile/bookings/approve?orgId=${orgId}`,
{
method: "POST",
body: JSON.stringify({ bookingId }),
}
),

/**
* Cancel a booking (RESERVED/ONGOING/OVERDUE -> CANCELLED). Frees the
* assets/kits; status guard + ownership enforced server-side.
Expand Down
22 changes: 22 additions & 0 deletions apps/companion/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,28 @@ export type BookingDetail = {
* as `false` rather than blocking Reserve.
*/
hasAlreadyBookedAssets?: boolean;
/**
* True while this reservation request awaits a decision (the workspace
* requires approval for a role and the booking has no `approvedAt`).
* Optional for rolling-deploy safety: an older server omits it, and absent
* must read as `false` so nothing is hidden.
*/
isPendingApproval?: boolean;
/**
* Whether this caller may approve the pending request: the server's
* `booking:approve` answer (Owner, Administrator, Manager), false unless
* {@link isPendingApproval}. The booking screen offers "Approve request"
* only when true; the approve endpoint enforces the permission on its own.
* Absent on an older server.
*/
canApprove?: boolean;
/**
* True on a DRAFT whose reservation by this caller would wait for approval
* (workspace requires it, and the caller's role is one whose reservations
* do). The screen then words Reserve as a request. Absent on an older
* server, which reads as a plain reservation.
*/
reservationNeedsApproval?: boolean;
/**
* Segmented lifecycle progress (Booked / Partial / Fully out / Returned),
* computed server-side by the SAME shared helper the web booking overview
Expand Down
Loading
Loading