From bd30190f96b219657d26a6fe5d7b0a9b2632ac15 Mon Sep 17 00:00:00 2001
From: brooktewabe
Date: Wed, 1 Apr 2026 11:27:28 +0300
Subject: [PATCH] hotel booking and reservation pages
---
src/lib/hotel-adapters.ts | 171 ++++++++++++++++++++++++++++
src/pages/BookingDetailPage.tsx | 22 +++-
src/pages/BookingsPage.tsx | 186 ++++++++++++++++++------------
src/pages/ReservationsPage.tsx | 194 ++++++++++++++++++++++++--------
4 files changed, 451 insertions(+), 122 deletions(-)
create mode 100644 src/lib/hotel-adapters.ts
diff --git a/src/lib/hotel-adapters.ts b/src/lib/hotel-adapters.ts
new file mode 100644
index 0000000..74ac9bb
--- /dev/null
+++ b/src/lib/hotel-adapters.ts
@@ -0,0 +1,171 @@
+import type { Booking, BookingStatus, Room, RoomInventoryStatus } from "@/lib/types";
+
+export type ApiHotelRoom = {
+ id: string;
+ name: string;
+ roomType: string;
+ maxGuests: number;
+ baseRate: string | number;
+ operationalStatus: string;
+};
+
+export type ApiHotelCustomer = {
+ firstName: string;
+ lastName: string;
+ email?: string | null;
+ phone?: string | null;
+};
+
+export type ApiHotelBookingRow = {
+ id: string;
+ roomId: string;
+ customerId: string;
+ checkIn: string;
+ checkOut: string;
+ guestCount?: number;
+ status: string;
+ totalPrice?: string | number | null;
+ currency?: string;
+ discountCode?: string | null;
+ referralCode?: string | null;
+ flightPnr?: string | null;
+ arrivalTime?: string | null;
+ payLaterHold?: boolean;
+ createdAt?: string;
+ updatedAt?: string;
+ internalNotes?: unknown;
+ customer: ApiHotelCustomer;
+ room?: { id: string; name: string };
+};
+
+function mapOperationalStatus(s: string): RoomInventoryStatus {
+ switch (s) {
+ case "VACANT":
+ return "available";
+ case "OCCUPIED":
+ return "occupied";
+ case "NOT_READY":
+ return "maintenance";
+ default:
+ return "available";
+ }
+}
+
+export function mapApiRoomToRoom(r: ApiHotelRoom): Room {
+ const rate =
+ typeof r.baseRate === "string" ? parseFloat(r.baseRate) : Number(r.baseRate);
+ return {
+ id: r.id,
+ name: r.name,
+ roomTypeSlug: r.roomType,
+ maxGuests: r.maxGuests,
+ baseRate: Number.isFinite(rate) ? rate : 0,
+ status: mapOperationalStatus(r.operationalStatus),
+ };
+}
+
+function mapHotelBookingStatus(s: string): BookingStatus {
+ switch (s) {
+ case "HOLD":
+ return "held";
+ case "CONFIRMED":
+ case "CHECKED_IN":
+ case "CHECKED_OUT":
+ return "confirmed";
+ case "CANCELLED":
+ return "cancelled";
+ default:
+ return "draft";
+ }
+}
+
+function nightsBetween(checkIn: string, checkOut: string): number {
+ const a = new Date(checkIn.slice(0, 10)).getTime();
+ const b = new Date(checkOut.slice(0, 10)).getTime();
+ const n = Math.round((b - a) / 86400000);
+ return Math.max(1, n);
+}
+
+function asStringNotes(raw: unknown): string[] | undefined {
+ if (!Array.isArray(raw)) return undefined;
+ const out = raw.filter((x): x is string => typeof x === "string");
+ return out.length ? out : undefined;
+}
+
+export function mapApiBookingToBooking(row: ApiHotelBookingRow): Booking {
+ const c = row.customer;
+ const checkIn = row.checkIn.slice(0, 10);
+ const checkOut = row.checkOut.slice(0, 10);
+ const nights = nightsBetween(checkIn, checkOut);
+ const totalRaw = row.totalPrice;
+ const total =
+ totalRaw === undefined || totalRaw === null
+ ? 0
+ : typeof totalRaw === "string"
+ ? parseFloat(totalRaw)
+ : Number(totalRaw);
+ const safeTotal = Number.isFinite(total) ? total : 0;
+
+ return {
+ id: row.id,
+ guest: {
+ firstName: c.firstName,
+ lastName: c.lastName,
+ email: c.email ?? "",
+ phone: c.phone ?? "",
+ flightBookingNumber: row.flightPnr ?? "",
+ arrivalTime: row.arrivalTime ?? "",
+ },
+ checkIn,
+ checkOut,
+ guests: row.guestCount ?? 1,
+ roomId: row.roomId,
+ nights,
+ pricing: {
+ nightlySubtotal: safeTotal,
+ discountPercent: 0,
+ discountAmount: 0,
+ taxRate: 0,
+ taxAmount: 0,
+ total: safeTotal,
+ totalCents: Math.round(safeTotal * 100),
+ currency: row.currency ?? "ETB",
+ couponCode: row.discountCode ?? undefined,
+ },
+ status: mapHotelBookingStatus(row.status),
+ payLaterHold: row.payLaterHold ?? false,
+ referralCode: row.referralCode ?? undefined,
+ createdAt: row.createdAt ?? new Date().toISOString(),
+ updatedAt: row.updatedAt ?? new Date().toISOString(),
+ roomDisplayLabel: row.room?.name,
+ internalNotes: asStringNotes(row.internalNotes),
+ };
+}
+
+export function isLikelyApiHotelBooking(row: unknown): row is ApiHotelBookingRow {
+ if (!row || typeof row !== "object") return false;
+ const r = row as Record;
+ return (
+ typeof r.customer === "object" &&
+ r.customer !== null &&
+ typeof (r.customer as ApiHotelCustomer).firstName === "string"
+ );
+}
+
+export function isLikelyApiHotelRoom(row: unknown): row is ApiHotelRoom {
+ if (!row || typeof row !== "object") return false;
+ const r = row as Record;
+ return typeof r.operationalStatus === "string" && typeof r.roomType === "string";
+}
+
+/** Map UI filter to backend HotelBookingStatus query param. */
+export function bookingStatusToQuery(status: string): string | undefined {
+ if (status === "all") return undefined;
+ const map: Record = {
+ confirmed: "CONFIRMED",
+ held: "HOLD",
+ cancelled: "CANCELLED",
+ payment_pending: "HOLD",
+ };
+ return map[status] ?? status.toUpperCase();
+}
diff --git a/src/pages/BookingDetailPage.tsx b/src/pages/BookingDetailPage.tsx
index 0431e5c..4d8c2d7 100644
--- a/src/pages/BookingDetailPage.tsx
+++ b/src/pages/BookingDetailPage.tsx
@@ -7,29 +7,41 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/context/AuthContext";
import { apiGet, apiPatch } from "@/lib/api";
+import { isLikelyApiHotelBooking, mapApiBookingToBooking } from "@/lib/hotel-adapters";
import type { Booking } from "@/lib/types";
+import { useAuthStore } from "@/store/authStore";
import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
import { roomDisplayName } from "@/lib/room-utils";
export function BookingDetailPage() {
const { id } = useParams<{ id: string }>();
+ const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [b, setB] = useState(null);
const [note, setNote] = useState("");
const { canEditBookings } = useAuth();
useEffect(() => {
if (!id) return;
- apiGet(`/bookings/${id}`).then(setB).catch(console.error);
- }, [id]);
+ apiGet(`/bookings/${id}`)
+ .then((raw) => {
+ if (isLikelyApiHotelBooking(raw)) setB(mapApiBookingToBooking(raw));
+ else setB(raw as Booking);
+ })
+ .catch(console.error);
+ }, [id, selectedPropertyId]);
if (!b) return Loading…
;
async function addNote() {
if (!b || !note.trim() || !canEditBookings) return;
- const next = await apiPatch(`/bookings/${b.id}`, {
+ const next = await apiPatch(`/bookings/${b.id}`, {
internalNotes: [note.trim()],
});
- setB(next);
+ setB(
+ isLikelyApiHotelBooking(next)
+ ? mapApiBookingToBooking(next)
+ : (next as Booking)
+ );
setNote("");
}
@@ -83,7 +95,7 @@ export function BookingDetailPage() {
nights)
Guests: {b.guests}
- Room: {roomDisplayName(b.roomId)}
+ Room: {b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
{b.holdReference && Hold: {b.holdReference}
}
{b.confirmationId && Payment ref: {b.confirmationId}
}
{b.paidAt && Paid: {formatDateTime(b.paidAt)}
}
diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx
index 2eca03d..446c5f4 100644
--- a/src/pages/BookingsPage.tsx
+++ b/src/pages/BookingsPage.tsx
@@ -28,30 +28,63 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { apiGet } from "@/lib/api";
+import { apiDownloadBlob, apiGet } from "@/lib/api";
+import {
+ bookingStatusToQuery,
+ isLikelyApiHotelBooking,
+ mapApiBookingToBooking,
+} from "@/lib/hotel-adapters";
import type { Booking } from "@/lib/types";
import { formatDate, formatMoney } from "@/lib/format";
import { roomDisplayName } from "@/lib/room-utils";
+import { Spinner } from "@/components/ui/spinner";
+import { useAuthStore } from "@/store/authStore";
export function BookingsPage() {
+ const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [searchParams] = useSearchParams();
const ref = searchParams.get("referral") ?? "";
const [list, setList] = useState([]);
+ const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("all");
const [q, setQ] = useState("");
useEffect(() => {
const params = new URLSearchParams();
- if (status !== "all") params.set("status", status);
+ const apiStatus = bookingStatusToQuery(status);
+ if (apiStatus) params.set("status", apiStatus);
if (q) params.set("q", q);
if (ref) params.set("referralCode", ref);
const t = setTimeout(() => {
- apiGet<{ data: Booking[] }>(`/bookings?${params}`)
- .then((r) => setList(r.data))
- .catch(console.error);
+ setLoading(true);
+ apiGet<{ data: unknown[] }>(`/bookings?${params}`)
+ .then((r) => {
+ const mapped = r.data.map((row) =>
+ isLikelyApiHotelBooking(row)
+ ? mapApiBookingToBooking(row)
+ : (row as Booking)
+ );
+ setList(mapped);
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false));
}, 200);
return () => clearTimeout(t);
- }, [status, q, ref]);
+ }, [status, q, ref, selectedPropertyId]);
+
+ async function exportCsv() {
+ try {
+ const { blob, filename } = await apiDownloadBlob("/bookings/export");
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename ?? "bookings.csv";
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ console.error(e);
+ }
+ }
return (
@@ -77,10 +110,13 @@ export function BookingsPage() {
-
@@ -132,70 +168,78 @@ export function BookingsPage() {
-
-
-
-
- Guest
- Room
- Dates
- Status
- Total
-
-
-
-
+ {loading && list.length === 0 ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+ Guest
+ Room
+ Dates
+ Status
+ Total
+
+
+
+
+ {list.map((b) => (
+
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+
+ {b.guest.email}
+
+
+
+ {b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
+
+
+ {formatDate(b.checkIn)} → {formatDate(b.checkOut)}
+
+
+ {b.status}
+
+
+ {formatMoney(b.pricing.total)}
+
+
+
+ Details
+
+
+
+ ))}
+
+
+
+
{list.map((b) => (
-
-
-
- {b.guest.firstName} {b.guest.lastName}
-
-
- {b.guest.email}
-
-
-
- {roomDisplayName(b.roomId)}
-
-
- {formatDate(b.checkIn)} → {formatDate(b.checkOut)}
-
-
- {b.status}
-
-
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+
+ {formatDate(b.checkIn)} · {b.status}
+
+
{formatMoney(b.pricing.total)}
-
-
-
- Details
-
-
-
+
+
))}
-
-
-
-
- {list.map((b) => (
-
-
- {b.guest.firstName} {b.guest.lastName}
-
-
- {formatDate(b.checkIn)} · {b.status}
-
-
- {formatMoney(b.pricing.total)}
-
-
- ))}
-
+
+ >
+ )}
diff --git a/src/pages/ReservationsPage.tsx b/src/pages/ReservationsPage.tsx
index 37544ff..2d84e8b 100644
--- a/src/pages/ReservationsPage.tsx
+++ b/src/pages/ReservationsPage.tsx
@@ -4,46 +4,137 @@ import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiGet } from "@/lib/api";
-import type { Room } from "@/lib/types";
+import { useAuthStore } from "@/store/authStore";
-interface TimelineResp {
- days: string[];
- rooms: Room[];
- segments: {
- bookingId: string;
- guestName: string;
- roomId: string;
- start: string;
- end: string;
- status: string;
- paymentLabel: string;
- source: string;
- }[];
+type DayStatus = {
+ date: string;
+ status:
+ | "VACANT"
+ | "RESERVED"
+ | "CHECK_IN_OUT"
+ | "BLOCKED"
+ | "OCCUPIED";
+ clientName?: string;
+ bookingId?: string;
+ blockId?: string;
+ blockTitle?: string;
+};
+
+type RoomRow = {
+ roomId: string;
+ roomName: string;
+ roomType: string;
+ days: DayStatus[];
+};
+
+export type Segment = {
+ bookingId: string;
+ guestName: string;
+ roomId: string;
+ start: string;
+ end: string;
+ status: string;
+ paymentLabel: string;
+ source: string;
+};
+
+type TimelineResp = {
+ month: string; // "2026-03"
+ rooms: RoomRow[];
+ segments?: Segment[]; // or just define it inline
+};
+
+
+// convert your API shape into your current UI’s “segments” shape
+function toSegments(rooms: RoomRow[]): Segment[] {
+ const segments: Segment[] = [];
+
+ for (const r of rooms) {
+ let current: Segment | null = null;
+
+ for (const d of r.days) {
+ const status = d.status;
+ const isOpenDay = status === "VACANT" || status === "CHECK_IN_OUT";
+
+ if (d.clientName && d.bookingId) {
+ if (
+ !current ||
+ current.guestName !== d.clientName ||
+ current.bookingId !== d.bookingId
+ ) {
+ if (current) {
+ segments.push({ ...current });
+ }
+ current = {
+ bookingId: d.bookingId,
+ guestName: d.clientName,
+ roomId: r.roomId,
+ start: d.date,
+ end: d.date,
+ status: "BOOKED",
+ paymentLabel: "Credit",
+ source: "API",
+ };
+ } else {
+ current.end = d.date;
+ }
+ } else if (d.blockTitle && d.blockId) {
+ segments.push({
+ bookingId: d.blockId,
+ guestName: d.blockTitle,
+ roomId: r.roomId,
+ start: d.date,
+ end: d.date,
+ status: "BLOCKED",
+ paymentLabel: "Maintenance",
+ source: "BLOCK",
+ });
+ } else if (!isOpenDay && current) {
+ segments.push({ ...current });
+ current = null;
+ }
+ }
+
+ if (current) {
+ segments.push({ ...current });
+ }
+ }
+
+ return segments;
}
+
+
export function ReservationsPage() {
+ const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
const [data, setData] = useState(null);
useEffect(() => {
- apiGet(`/reservations/timeline?month=${month}`)
- .then(setData)
+ apiGet(`/reservations?month=${month}`)
+ .then((resp) => {
+ // here resp is the shape you pasted
+ setData(resp);
+ })
.catch(console.error);
- }, [month]);
+ }, [month, selectedPropertyId]);
- if (!data)
- return Loading timeline…
;
+ if (!data) return Loading timeline…
;
const dayWidth = 56;
const roomCol = 120;
+ // map backend shape → UI segments
+ const segments: Segment[] = toSegments(data.rooms);
+
+
return (
Reservations
- Gantt-style view (mock data)
+ Gantt-style view
- Occupied
- Check-in / out
+ Occupied
+ Check‑in / out
Reserved
+ Blocked
@@ -68,7 +160,7 @@ export function ReservationsPage() {
@@ -78,44 +170,53 @@ export function ReservationsPage() {
>
Room
- {data.days.map((d) => (
-
- {d.slice(8)}
-
- ))}
+ {Array.from({ length: 31 }, (_, i) => {
+ const d = i + 1;
+ return (
+
+ {d.toString()}
+
+ );
+ })}
+
{data.rooms.map((room) => (
-
+
- {room.name}
+ {room.roomName}
- {data.segments
- .filter((s) => s.roomId === room.id)
+ {segments
+ .filter((s) => s.roomId === room.roomId)
.map((s) => {
- const startIdx = data.days.findIndex(
- (d) => d >= s.start
- );
- const endIdx = data.days.findIndex((d) => d >= s.end);
- const si =
- startIdx >= 0 ? startIdx : 0;
- const ei =
- endIdx >= 0 ? endIdx : data.days.length;
+ const startIdx =
+ parseInt(s.start.slice(8), 10) - 1;
+ const endIdx =
+ parseInt(s.end.slice(8), 10) - 1;
+
+ const si = Math.max(0, startIdx);
+ const ei = Math.min(31, endIdx + 1);
+
const span = Math.max(1, ei - si);
+
return (
);
}
+