hotel booking and reservation pages
This commit is contained in:
parent
893fdc1669
commit
bd30190f96
171
src/lib/hotel-adapters.ts
Normal file
171
src/lib/hotel-adapters.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, string> = {
|
||||||
|
confirmed: "CONFIRMED",
|
||||||
|
held: "HOLD",
|
||||||
|
cancelled: "CANCELLED",
|
||||||
|
payment_pending: "HOLD",
|
||||||
|
};
|
||||||
|
return map[status] ?? status.toUpperCase();
|
||||||
|
}
|
||||||
|
|
@ -7,29 +7,41 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { apiGet, apiPatch } from "@/lib/api";
|
import { apiGet, apiPatch } from "@/lib/api";
|
||||||
|
import { isLikelyApiHotelBooking, mapApiBookingToBooking } from "@/lib/hotel-adapters";
|
||||||
import type { Booking } from "@/lib/types";
|
import type { Booking } from "@/lib/types";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
|
import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
|
||||||
import { roomDisplayName } from "@/lib/room-utils";
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
|
||||||
export function BookingDetailPage() {
|
export function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [b, setB] = useState<Booking | null>(null);
|
const [b, setB] = useState<Booking | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const { canEditBookings } = useAuth();
|
const { canEditBookings } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
apiGet<Booking>(`/bookings/${id}`).then(setB).catch(console.error);
|
apiGet<unknown>(`/bookings/${id}`)
|
||||||
}, [id]);
|
.then((raw) => {
|
||||||
|
if (isLikelyApiHotelBooking(raw)) setB(mapApiBookingToBooking(raw));
|
||||||
|
else setB(raw as Booking);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [id, selectedPropertyId]);
|
||||||
|
|
||||||
if (!b) return <p className="text-muted-foreground">Loading…</p>;
|
if (!b) return <p className="text-muted-foreground">Loading…</p>;
|
||||||
|
|
||||||
async function addNote() {
|
async function addNote() {
|
||||||
if (!b || !note.trim() || !canEditBookings) return;
|
if (!b || !note.trim() || !canEditBookings) return;
|
||||||
const next = await apiPatch<Booking>(`/bookings/${b.id}`, {
|
const next = await apiPatch<unknown>(`/bookings/${b.id}`, {
|
||||||
internalNotes: [note.trim()],
|
internalNotes: [note.trim()],
|
||||||
});
|
});
|
||||||
setB(next);
|
setB(
|
||||||
|
isLikelyApiHotelBooking(next)
|
||||||
|
? mapApiBookingToBooking(next)
|
||||||
|
: (next as Booking)
|
||||||
|
);
|
||||||
setNote("");
|
setNote("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +95,7 @@ export function BookingDetailPage() {
|
||||||
nights)
|
nights)
|
||||||
</p>
|
</p>
|
||||||
<p>Guests: {b.guests}</p>
|
<p>Guests: {b.guests}</p>
|
||||||
<p>Room: {roomDisplayName(b.roomId)}</p>
|
<p>Room: {b.roomDisplayLabel ?? roomDisplayName(b.roomId)}</p>
|
||||||
{b.holdReference && <p>Hold: {b.holdReference}</p>}
|
{b.holdReference && <p>Hold: {b.holdReference}</p>}
|
||||||
{b.confirmationId && <p>Payment ref: {b.confirmationId}</p>}
|
{b.confirmationId && <p>Payment ref: {b.confirmationId}</p>}
|
||||||
{b.paidAt && <p>Paid: {formatDateTime(b.paidAt)}</p>}
|
{b.paidAt && <p>Paid: {formatDateTime(b.paidAt)}</p>}
|
||||||
|
|
|
||||||
|
|
@ -28,30 +28,63 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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 type { Booking } from "@/lib/types";
|
||||||
import { formatDate, formatMoney } from "@/lib/format";
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { roomDisplayName } from "@/lib/room-utils";
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function BookingsPage() {
|
export function BookingsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const ref = searchParams.get("referral") ?? "";
|
const ref = searchParams.get("referral") ?? "";
|
||||||
const [list, setList] = useState<Booking[]>([]);
|
const [list, setList] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [status, setStatus] = useState<string>("all");
|
const [status, setStatus] = useState<string>("all");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
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 (q) params.set("q", q);
|
||||||
if (ref) params.set("referralCode", ref);
|
if (ref) params.set("referralCode", ref);
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
apiGet<{ data: Booking[] }>(`/bookings?${params}`)
|
setLoading(true);
|
||||||
.then((r) => setList(r.data))
|
apiGet<{ data: unknown[] }>(`/bookings?${params}`)
|
||||||
.catch(console.error);
|
.then((r) => {
|
||||||
|
const mapped = r.data.map((row) =>
|
||||||
|
isLikelyApiHotelBooking(row)
|
||||||
|
? mapApiBookingToBooking(row)
|
||||||
|
: (row as Booking)
|
||||||
|
);
|
||||||
|
setList(mapped);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => clearTimeout(t);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -77,10 +110,13 @@ export function BookingsPage() {
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/bookings/new">+ New booking</Link>
|
<Link to="/bookings/new">+ New booking</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button
|
||||||
<a href="/api/export/bookings.csv" download>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void exportCsv()}
|
||||||
|
disabled={!selectedPropertyId}
|
||||||
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,6 +168,12 @@ export function BookingsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{loading && list.length === 0 ? (
|
||||||
|
<div className="flex min-h-[300px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="hidden md:block overflow-x-auto">
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -156,7 +198,7 @@ export function BookingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{roomDisplayName(b.roomId)}
|
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
||||||
|
|
@ -196,6 +238,8 @@ export function BookingsPage() {
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,30 @@ import { useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import type { Room } from "@/lib/types";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
interface TimelineResp {
|
type DayStatus = {
|
||||||
days: string[];
|
date: string;
|
||||||
rooms: Room[];
|
status:
|
||||||
segments: {
|
| "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;
|
bookingId: string;
|
||||||
guestName: string;
|
guestName: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
|
@ -18,32 +36,105 @@ interface TimelineResp {
|
||||||
status: string;
|
status: string;
|
||||||
paymentLabel: string;
|
paymentLabel: string;
|
||||||
source: 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() {
|
export function ReservationsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
|
const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
|
||||||
const [data, setData] = useState<TimelineResp | null>(null);
|
const [data, setData] = useState<TimelineResp | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<TimelineResp>(`/reservations/timeline?month=${month}`)
|
apiGet<TimelineResp>(`/reservations?month=${month}`)
|
||||||
.then(setData)
|
.then((resp) => {
|
||||||
|
// here resp is the shape you pasted
|
||||||
|
setData(resp);
|
||||||
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [month]);
|
}, [month, selectedPropertyId]);
|
||||||
|
|
||||||
if (!data)
|
if (!data) return <p className="text-muted-foreground">Loading timeline…</p>;
|
||||||
return <p className="text-muted-foreground">Loading timeline…</p>;
|
|
||||||
|
|
||||||
const dayWidth = 56;
|
const dayWidth = 56;
|
||||||
const roomCol = 120;
|
const roomCol = 120;
|
||||||
|
|
||||||
|
// map backend shape → UI segments
|
||||||
|
const segments: Segment[] = toSegments(data.rooms);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Reservations</h1>
|
<h1 className="text-2xl font-bold">Reservations</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Gantt-style view (mock data)
|
Gantt-style view
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -55,9 +146,10 @@ export function ReservationsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge>Occupied</Badge>
|
<Badge variant="default">Occupied</Badge>
|
||||||
<Badge variant="secondary">Check-in / out</Badge>
|
<Badge variant="secondary">Check‑in / out</Badge>
|
||||||
<Badge variant="outline">Reserved</Badge>
|
<Badge variant="outline">Reserved</Badge>
|
||||||
|
<Badge variant="destructive">Blocked</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-2xl">
|
||||||
|
|
@ -68,7 +160,7 @@ export function ReservationsPage() {
|
||||||
<div
|
<div
|
||||||
className="relative min-w-max"
|
className="relative min-w-max"
|
||||||
style={{
|
style={{
|
||||||
width: roomCol + data.days.length * dayWidth,
|
width: roomCol + 31 * dayWidth, // 31 days max
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex border-b">
|
<div className="flex border-b">
|
||||||
|
|
@ -78,44 +170,53 @@ export function ReservationsPage() {
|
||||||
>
|
>
|
||||||
Room
|
Room
|
||||||
</div>
|
</div>
|
||||||
{data.days.map((d) => (
|
{Array.from({ length: 31 }, (_, i) => {
|
||||||
|
const d = i + 1;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={d}
|
key={d}
|
||||||
className="shrink-0 border-r p-1 text-center text-[10px] text-muted-foreground"
|
className="shrink-0 border-r p-1 text-center text-[10px] text-muted-foreground"
|
||||||
style={{ width: dayWidth }}
|
style={{ width: dayWidth }}
|
||||||
>
|
>
|
||||||
{d.slice(8)}
|
{d.toString()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.rooms.map((room) => (
|
{data.rooms.map((room) => (
|
||||||
<div key={room.id} className="flex border-b">
|
<div key={room.roomId} className="flex border-b">
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-r p-2 text-xs font-medium"
|
className="shrink-0 border-r p-2 text-xs font-medium"
|
||||||
style={{ width: roomCol }}
|
style={{ width: roomCol }}
|
||||||
>
|
>
|
||||||
{room.name}
|
{room.roomName}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="relative shrink-0"
|
className="relative shrink-0"
|
||||||
style={{ width: data.days.length * dayWidth, height: 48 }}
|
style={{ width: 31 * dayWidth, height: 48 }}
|
||||||
>
|
>
|
||||||
{data.segments
|
{segments
|
||||||
.filter((s) => s.roomId === room.id)
|
.filter((s) => s.roomId === room.roomId)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const startIdx = data.days.findIndex(
|
const startIdx =
|
||||||
(d) => d >= s.start
|
parseInt(s.start.slice(8), 10) - 1;
|
||||||
);
|
const endIdx =
|
||||||
const endIdx = data.days.findIndex((d) => d >= s.end);
|
parseInt(s.end.slice(8), 10) - 1;
|
||||||
const si =
|
|
||||||
startIdx >= 0 ? startIdx : 0;
|
const si = Math.max(0, startIdx);
|
||||||
const ei =
|
const ei = Math.min(31, endIdx + 1);
|
||||||
endIdx >= 0 ? endIdx : data.days.length;
|
|
||||||
const span = Math.max(1, ei - si);
|
const span = Math.max(1, ei - si);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={s.bookingId}
|
key={s.bookingId}
|
||||||
className="absolute top-2 flex h-8 items-center rounded-lg border bg-accent px-2 text-[10px] shadow-sm"
|
className={`absolute top-2 flex h-8 items-center rounded-lg border px-2 text-[10px] shadow-sm ${
|
||||||
|
s.status === "BLOCKED"
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-accent"
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
left: si * dayWidth + 4,
|
left: si * dayWidth + 4,
|
||||||
width: span * dayWidth - 8,
|
width: span * dayWidth - 8,
|
||||||
|
|
@ -137,3 +238,4 @@ export function ReservationsPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user