updated dashboard

This commit is contained in:
brooktewabe 2026-04-07 12:36:36 +03:00
parent 5a47d48467
commit cb5ad19655

View File

@ -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<string, number>;
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<DashboardPayload | HotelSummaryResponse | null>(
null
);
const [data, setData] = useState<DashboardState | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
setErr(null);
setData(null);
apiGet<DashboardPayload | HotelSummaryResponse>(`/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<VisitSeriesResponse>(`/analytics/visits?${qTrends}`),
apiGet<{ data: unknown[] }>("/rooms"),
apiGet<BookingTrendDay[]>(`/analytics/booking-trends?${qTrends}`),
apiGet<RatingSnapshot>("/analytics/rating"),
apiGet<ExtraRevenues>(`/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 <p className="text-destructive">{err}</p>;
if (!data) return (
<div className="flex min-h-[400px] items-center justify-center">
<Spinner size={32} />
</div>
);
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
{ 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) => (
<Card key={c.label} className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{c.label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{c.value}</p>
</CardContent>
</Card>
))}
</div>
{data.bookingsByStatus && Object.keys(data.bookingsByStatus).length > 0 ? (
<Card className="rounded-2xl">
<CardHeader>
<CardTitle className="text-base">Bookings by status</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{Object.entries(data.bookingsByStatus).map(([k, v]) => (
<Badge key={k} variant="secondary">
{k}: {v}
</Badge>
))}
</CardContent>
</Card>
) : null}
<div className="flex min-h-[400px] items-center justify-center">
<Spinner size={32} />
</div>
);
}
if (!isDashboardPayload(data)) {
return (
<p className="text-muted-foreground">
Unexpected dashboard response. Try again or check the API.
</p>
);
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Bookings, visits, and revenue snapshot
</p>
<p className="text-muted-foreground">Bookings, visits, and property snapshot</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
@ -160,7 +288,7 @@ export function DashboardPage() {
<CardContent>
<div className="h-[240px] w-full min-h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.bookingSeries}>
<LineChart data={bookingChartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
@ -226,11 +354,11 @@ export function DashboardPage() {
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.heatmap.map((h) => (
{data.heatmap.map((h, index) => (
<div
key={h.roomId}
key={`${h.roomId}-${index}`}
title={h.roomId}
className="size-10 rounded-lg border text-[10px] font-medium flex items-center justify-center"
className="flex size-10 items-center justify-center rounded-lg border text-[10px] font-medium"
style={{
background:
h.state === "occupied"
@ -243,18 +371,16 @@ export function DashboardPage() {
color: h.state === "occupied" ? "#fff" : "inherit",
}}
>
{h.roomId.slice(-2)}
{shortRoomLabel(h.roomId, index)}
</div>
))}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-[var(--chart-5)]" />{" "}
Vacant
<span className="size-2 rounded-full bg-[var(--chart-5)]" /> Vacant
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-[var(--chart-1)]" />{" "}
Occupied
<span className="size-2 rounded-full bg-[var(--chart-1)]" /> Occupied
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-muted" /> Not ready
@ -263,29 +389,35 @@ export function DashboardPage() {
</CardContent>
</Card>
<Card className="rounded-2xl overflow-hidden">
<Card className="overflow-hidden rounded-2xl">
<CardHeader>
<CardTitle className="text-base">Guest rating</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-4xl font-bold">{data.rating.score}</p>
<p className="text-sm text-muted-foreground">{data.rating.label}</p>
{data.rating.imageUrl && (
<img
src={data.rating.imageUrl}
alt=""
className="mt-2 h-28 w-full rounded-xl object-cover"
/>
)}
<div className="flex gap-4 pt-2 text-sm">
<p className="text-4xl font-bold">{ratingDisplay}</p>
<p className="text-sm text-muted-foreground">{ratingSub}</p>
<img
src="https://images.unsplash.com/photo-1566073771259-6a8506099945?auto=format&fit=crop&w=1200&q=80"
alt="Hotel preview"
className="mt-2 h-28 w-full rounded-xl object-cover"
/>
<div className="flex flex-wrap gap-4 pt-2 text-sm">
<div>
<p className="text-muted-foreground">Discount uses</p>
<p className="font-semibold">{data.codeStats.discountRedemptions}</p>
<p className="font-semibold">{data.rating?.discountUses ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Referrals</p>
<p className="font-semibold">{data.codeStats.referralRedemptions}</p>
<p className="text-muted-foreground">Referral bookings</p>
<p className="font-semibold">{data.rating?.referralBookings ?? "—"}</p>
</div>
{data.pointsSummary && (
<div>
<p className="text-muted-foreground">Points (30d)</p>
<p className="text-xs font-semibold">
+{data.pointsSummary.issued} / {data.pointsSummary.redeemed}
</p>
</div>
)}
</div>
</CardContent>
</Card>
@ -297,17 +429,18 @@ export function DashboardPage() {
<CardTitle className="text-base">Extra revenue</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data.revenueExtras.map((r) => (
{extraBars.length === 0 && (
<p className="text-sm text-muted-foreground">No extra revenue in range yet.</p>
)}
{extraBars.map((r) => (
<div key={r.label}>
<div className="mb-1 flex justify-between text-sm">
<span>{r.label}</span>
<span className="text-muted-foreground">
{formatMoney(r.current)} / {formatMoney(r.target)}
{formatMoney(r.value, "ETB")} (30d)
</span>
</div>
<Progress
value={Math.min(100, (r.current / r.target) * 100)}
/>
<Progress value={Math.min(100, r.pct)} />
</div>
))}
</CardContent>
@ -318,15 +451,18 @@ export function DashboardPage() {
<CardTitle className="text-base">Upcoming</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{data.calendarEvents.map((e) => (
{data.upcoming.length === 0 && (
<p className="text-sm text-muted-foreground">
No check-ins in the next two weeks.
</p>
)}
{data.upcoming.map((e) => (
<div
key={e.id}
className="rounded-xl border-l-4 border-l-primary bg-muted/30 px-3 py-2 text-sm"
>
<p className="font-medium">{e.title}</p>
<p className="text-xs text-muted-foreground">
{formatDate(e.date)}
</p>
<p className="text-xs text-muted-foreground">{formatDate(e.date)}</p>
</div>
))}
</CardContent>
@ -349,13 +485,13 @@ export function DashboardPage() {
</TableRow>
</TableHeader>
<TableBody>
{data.recentBookings.map((b: Booking) => (
{data.recentBookings.map((b) => (
<TableRow key={b.id}>
<TableCell>
{b.guest.firstName} {b.guest.lastName}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(b.checkIn)} {formatDate(b.checkOut)}
<TableCell className="text-xs text-muted-foreground">
{formatDate(b.checkIn)} to {formatDate(b.checkOut)}
</TableCell>
<TableCell className="text-xs">
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
@ -372,17 +508,15 @@ export function DashboardPage() {
</Table>
</CardContent>
<CardContent className="space-y-3 md:hidden">
{data.recentBookings.map((b: Booking) => (
{data.recentBookings.map((b) => (
<div key={b.id} className="rounded-xl border p-3 text-sm">
<p className="font-medium">
{b.guest.firstName} {b.guest.lastName}
</p>
<p className="text-muted-foreground text-xs">
{formatDate(b.checkIn)} {b.status}
</p>
<p className="mt-1 font-semibold">
{formatMoney(b.pricing.total)}
<p className="text-xs text-muted-foreground">
{formatDate(b.checkIn)} - {b.status}
</p>
<p className="mt-1 font-semibold">{formatMoney(b.pricing.total)}</p>
</div>
))}
</CardContent>