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)} + + + + +
+ ))} +
+
+
+
{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)} - - - - - +

+ ))} - - -
-
- {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 (
); } +