From cb5ad1965580499d3bc5ae691c8a078b62f42864 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Tue, 7 Apr 2026 12:36:36 +0300 Subject: [PATCH] updated dashboard --- src/pages/DashboardPage.tsx | 398 ++++++++++++++++++++++++------------ 1 file changed, 266 insertions(+), 132 deletions(-) diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index fad6af8..1629d76 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { addDays, format, parseISO } from "date-fns"; import { Area, AreaChart, @@ -14,6 +15,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -22,13 +25,18 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Progress } from "@/components/ui/progress"; import { apiGet } from "@/lib/api"; +import { + isLikelyApiHotelBooking, + isLikelyApiHotelRoom, + mapApiBookingToBooking, + mapApiRoomToRoom, +} from "@/lib/hotel-adapters"; import { formatDate, formatMoney } from "@/lib/format"; +import type { BookingTrendDay, RatingSnapshot } from "@/lib/hotel-staff-types"; import { roomDisplayName } from "@/lib/room-utils"; -import { Spinner } from "@/components/ui/spinner"; +import type { Booking, Room } from "@/lib/types"; import { useAuthStore } from "@/store/authStore"; -import type { Booking, DashboardPayload } from "@/lib/types"; const tooltipStyle = { backgroundColor: "var(--navy)", @@ -37,119 +45,239 @@ const tooltipStyle = { color: "#fff", }; -type HotelSummaryResponse = { - arrivalsToday?: number; - arrivals?: number; - departuresToday?: number; - departures?: number; - unpaidHolds?: number; - revenueMonth?: string | number; - bookingsByStatus?: Record; +type VisitSeriesResponse = { + series: Array<{ date: string; count: number }>; }; -function isDashboardPayload(d: unknown): d is DashboardPayload { - return typeof d === "object" && d !== null && "bookingSeries" in d; +type ExtraRevenues = { + roomService: string; + laundry: string; + gymSpa: string; + total: string; +}; + +type DashboardState = { + recentBookings: Booking[]; + visitsSeries: { date: string; views: number; sessions: number }[]; + heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[]; + bookingTrends: BookingTrendDay[]; + rating: RatingSnapshot | null; + extraRevenues: ExtraRevenues | null; + pointsSummary: { issued: number; redeemed: number } | null; + upcoming: { id: string; title: string; date: string }[]; +}; + +function chartDayLabel(isoDate: string) { + try { + return format(parseISO(isoDate), "MMM d"); + } catch { + return isoDate; + } } -function isHotelSummary(d: unknown): d is HotelSummaryResponse { - if (typeof d !== "object" || d === null) return false; - const x = d as HotelSummaryResponse; - return ( - typeof x.arrivalsToday === "number" || - typeof x.arrivals === "number" || - x.bookingsByStatus !== undefined - ); +function ratingLabel(score: number): string { + if (score >= 4.5) return "Impressive"; + if (score >= 3.5) return "Solid"; + if (score >= 2.5) return "Fair"; + return "Growing"; +} + +function mapRoomState(room: Room): "vacant" | "not_ready" | "occupied" | "unavailable" { + switch (room.status) { + case "occupied": + return "occupied"; + case "maintenance": + return "not_ready"; + case "out_of_order": + return "unavailable"; + default: + return "vacant"; + } +} + +function shortRoomLabel(roomId: string, index: number) { + const digits = roomId.match(/\d+/g)?.join("").slice(-2); + if (digits) return digits.padStart(2, "0"); + return String(index + 1).padStart(2, "0"); } export function DashboardPage() { const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId); - const [data, setData] = useState( - null - ); + const [data, setData] = useState(null); const [err, setErr] = useState(null); useEffect(() => { - setErr(null); - setData(null); - apiGet(`/dashboard/summary`) - .then(setData) - .catch((e) => setErr(String(e))); + let active = true; + + async function load() { + setErr(null); + setData(null); + + const to = new Date(); + const from = addDays(to, -14); + const from30 = addDays(to, -30); + const qTrends = `from=${from.toISOString()}&to=${to.toISOString()}`; + const qExtras = `from=${from30.toISOString()}&to=${to.toISOString()}`; + + const [ + bookingsRes, + visitsRes, + roomsRes, + trendsRes, + ratingRes, + extrasRes, + pointsRes, + ] = await Promise.allSettled([ + apiGet<{ data: unknown[] }>("/bookings"), + apiGet(`/analytics/visits?${qTrends}`), + apiGet<{ data: unknown[] }>("/rooms"), + apiGet(`/analytics/booking-trends?${qTrends}`), + apiGet("/analytics/rating"), + apiGet(`/analytics/extra-revenues?${qExtras}`), + apiGet<{ issued: number; redeemed: number }>( + `/analytics/points-summary?${qExtras}` + ), + ]); + + if (!active) return; + + const next: DashboardState = { + recentBookings: [], + visitsSeries: [], + heatmap: [], + bookingTrends: [], + rating: null, + extraRevenues: null, + pointsSummary: null, + upcoming: [], + }; + + const failures: string[] = []; + + if (bookingsRes.status === "fulfilled") { + const allBookings = bookingsRes.value.data + .map((row) => + isLikelyApiHotelBooking(row) ? mapApiBookingToBooking(row) : (row as Booking) + ) + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + next.recentBookings = allBookings.slice(0, 5); + const soon = addDays(new Date(), 14); + next.upcoming = allBookings + .filter( + (b) => + b.status !== "cancelled" && + Date.parse(b.checkIn) >= Date.now() && + Date.parse(b.checkIn) <= soon.getTime() + ) + .slice(0, 6) + .map((b) => ({ + id: b.id, + title: `${b.guest.firstName} ${b.guest.lastName} — check-in`, + date: b.checkIn, + })); + } else { + failures.push("bookings"); + } + + if (visitsRes.status === "fulfilled") { + next.visitsSeries = visitsRes.value.series.slice(-14).map((item) => ({ + date: item.date, + views: item.count || 0, + sessions: 0, + })); + } else { + failures.push("visits"); + } + + if (roomsRes.status === "fulfilled") { + next.heatmap = roomsRes.value.data + .map((row) => (isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room))) + .map((room) => ({ + roomId: room.name || room.id, + state: mapRoomState(room), + })); + } else { + failures.push("rooms"); + } + + if (trendsRes.status === "fulfilled") { + next.bookingTrends = trendsRes.value; + } + + if (ratingRes.status === "fulfilled") { + next.rating = ratingRes.value; + } + + if (extrasRes.status === "fulfilled") { + next.extraRevenues = extrasRes.value; + } + + if (pointsRes.status === "fulfilled") { + next.pointsSummary = pointsRes.value; + } + + if (failures.length === 3) { + setErr("Unable to load dashboard data."); + return; + } + + setData(next); + } + + void load(); + + return () => { + active = false; + }; }, [selectedPropertyId]); if (err) return

{err}

; - if (!data) return ( -
- -
- ); - - if (isHotelSummary(data) && !isDashboardPayload(data)) { - const arrivals = data.arrivalsToday ?? data.arrivals ?? 0; - const departures = data.departuresToday ?? data.departures ?? 0; - const revenueRaw = data.revenueMonth ?? 0; - const revenueNum = - typeof revenueRaw === "string" ? parseFloat(revenueRaw) : Number(revenueRaw); - + if (!data) { return ( -
-
-

Dashboard

-
-
- {[ - { label: "Arrivals today", value: arrivals }, - { label: "Departures today", value: departures }, - { label: "Unpaid holds", value: data.unpaidHolds ?? 0 }, - { - label: "Revenue (month)", - value: formatMoney(Number.isFinite(revenueNum) ? revenueNum : 0), - }, - ].map((c) => ( - - - - {c.label} - - - -

{c.value}

-
-
- ))} -
- {data.bookingsByStatus && Object.keys(data.bookingsByStatus).length > 0 ? ( - - - Bookings by status - - - {Object.entries(data.bookingsByStatus).map(([k, v]) => ( - - {k}: {v} - - ))} - - - ) : null} +
+
); } - if (!isDashboardPayload(data)) { - return ( -

- Unexpected dashboard response. Try again or check the API. -

- ); + const bookingChartData = + data.bookingTrends.length > 0 + ? data.bookingTrends.map((d) => ({ + date: chartDayLabel(d.date), + total: d.total, + online: d.online, + cancelled: d.cancelled, + })) + : [{ date: "—", total: 0, online: 0, cancelled: 0 }]; + + let extraBars: { label: string; value: number; pct: number }[] = []; + if (data.extraRevenues) { + const room = Number(data.extraRevenues.roomService) || 0; + const laundry = Number(data.extraRevenues.laundry) || 0; + const gym = Number(data.extraRevenues.gymSpa) || 0; + const maxVal = Math.max(room, laundry, gym, 1); + extraBars = [ + { label: "Room service", value: room, pct: (room / maxVal) * 100 }, + { label: "Laundry", value: laundry, pct: (laundry / maxVal) * 100 }, + { label: "Spa / gym", value: gym, pct: (gym / maxVal) * 100 }, + ]; } + const ratingScore = data.rating?.averageRating ?? 0; + const ratingDisplay = + data.rating && data.rating.reviewCount > 0 + ? ratingScore.toFixed(1) + : "—"; + const ratingSub = + data.rating && data.rating.reviewCount > 0 + ? ratingLabel(ratingScore) + : "No reviews yet"; + return (

Dashboard

-

- Bookings, visits, and revenue snapshot -

+

Bookings, visits, and property snapshot

@@ -160,7 +288,7 @@ export function DashboardPage() {
- + @@ -226,11 +354,11 @@ export function DashboardPage() {
- {data.heatmap.map((h) => ( + {data.heatmap.map((h, index) => (
- {h.roomId.slice(-2)} + {shortRoomLabel(h.roomId, index)}
))}
- {" "} - Vacant + Vacant - {" "} - Occupied + Occupied Not ready @@ -263,29 +389,35 @@ export function DashboardPage() { - + Guest rating -

{data.rating.score}

-

{data.rating.label}

- {data.rating.imageUrl && ( - - )} -
+

{ratingDisplay}

+

{ratingSub}

+ Hotel preview +

Discount uses

-

{data.codeStats.discountRedemptions}

+

{data.rating?.discountUses ?? "—"}

-

Referrals

-

{data.codeStats.referralRedemptions}

+

Referral bookings

+

{data.rating?.referralBookings ?? "—"}

+ {data.pointsSummary && ( +
+

Points (30d)

+

+ +{data.pointsSummary.issued} / −{data.pointsSummary.redeemed} +

+
+ )}
@@ -297,17 +429,18 @@ export function DashboardPage() { Extra revenue - {data.revenueExtras.map((r) => ( + {extraBars.length === 0 && ( +

No extra revenue in range yet.

+ )} + {extraBars.map((r) => (
{r.label} - {formatMoney(r.current)} / {formatMoney(r.target)} + {formatMoney(r.value, "ETB")} (30d)
- +
))}
@@ -318,15 +451,18 @@ export function DashboardPage() { Upcoming - {data.calendarEvents.map((e) => ( + {data.upcoming.length === 0 && ( +

+ No check-ins in the next two weeks. +

+ )} + {data.upcoming.map((e) => (

{e.title}

-

- {formatDate(e.date)} -

+

{formatDate(e.date)}

))}
@@ -349,13 +485,13 @@ export function DashboardPage() { - {data.recentBookings.map((b: Booking) => ( + {data.recentBookings.map((b) => ( {b.guest.firstName} {b.guest.lastName} - - {formatDate(b.checkIn)} → {formatDate(b.checkOut)} + + {formatDate(b.checkIn)} to {formatDate(b.checkOut)} {b.roomDisplayLabel ?? roomDisplayName(b.roomId)} @@ -372,17 +508,15 @@ export function DashboardPage() { - {data.recentBookings.map((b: Booking) => ( + {data.recentBookings.map((b) => (

{b.guest.firstName} {b.guest.lastName}

-

- {formatDate(b.checkIn)} — {b.status} -

-

- {formatMoney(b.pricing.total)} +

+ {formatDate(b.checkIn)} - {b.status}

+

{formatMoney(b.pricing.total)}

))}