527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { addDays, format, parseISO } from "date-fns";
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
CartesianGrid,
|
||
Legend,
|
||
Line,
|
||
LineChart,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
|
||
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,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
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 type { Booking, Room } from "@/lib/types";
|
||
import { useAuthStore } from "@/store/authStore";
|
||
|
||
const tooltipStyle = {
|
||
backgroundColor: "var(--navy)",
|
||
border: "none",
|
||
borderRadius: "8px",
|
||
color: "#fff",
|
||
};
|
||
|
||
type VisitSeriesResponse = {
|
||
series: Array<{ date: string; count: number }>;
|
||
};
|
||
|
||
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 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<DashboardState | null>(null);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
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>
|
||
);
|
||
}
|
||
|
||
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 property snapshot</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Card className="rounded-2xl">
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<CardTitle className="text-base">Booking trends</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-[240px] w-full min-h-[220px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={bookingChartData}>
|
||
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||
<YAxis tick={{ fontSize: 11 }} />
|
||
<Tooltip contentStyle={tooltipStyle} />
|
||
<Legend />
|
||
<Line
|
||
type="monotone"
|
||
dataKey="total"
|
||
stroke="var(--chart-1)"
|
||
strokeWidth={2}
|
||
name="Total"
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="online"
|
||
stroke="var(--chart-2)"
|
||
strokeWidth={2}
|
||
name="Online"
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="cancelled"
|
||
stroke="var(--chart-4)"
|
||
name="Cancelled"
|
||
/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="rounded-2xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Site visits</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-[240px] w-full min-h-[220px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<AreaChart data={data.visitsSeries}>
|
||
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||
<YAxis tick={{ fontSize: 11 }} />
|
||
<Tooltip contentStyle={tooltipStyle} />
|
||
<Area
|
||
type="monotone"
|
||
dataKey="views"
|
||
stroke="var(--chart-2)"
|
||
fill="var(--chart-3)"
|
||
fillOpacity={0.35}
|
||
name="Views"
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-3">
|
||
<Card className="rounded-2xl lg:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Room status</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-wrap gap-2">
|
||
{data.heatmap.map((h, index) => (
|
||
<div
|
||
key={`${h.roomId}-${index}`}
|
||
title={h.roomId}
|
||
className="flex size-10 items-center justify-center rounded-lg border text-[10px] font-medium"
|
||
style={{
|
||
background:
|
||
h.state === "occupied"
|
||
? "var(--chart-1)"
|
||
: h.state === "vacant"
|
||
? "var(--chart-5)"
|
||
: h.state === "not_ready"
|
||
? "var(--muted)"
|
||
: "transparent",
|
||
color: h.state === "occupied" ? "#fff" : "inherit",
|
||
}}
|
||
>
|
||
{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>
|
||
<span className="flex items-center gap-1">
|
||
<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
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<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">{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.rating?.discountUses ?? "—"}</p>
|
||
</div>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Card className="rounded-2xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Extra revenue</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{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.value, "ETB")} (30d)
|
||
</span>
|
||
</div>
|
||
<Progress value={Math.min(100, r.pct)} />
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="rounded-2xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Upcoming</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{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>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="rounded-2xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Recent bookings</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="hidden md:block">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Guest</TableHead>
|
||
<TableHead>Stay</TableHead>
|
||
<TableHead>Room type</TableHead>
|
||
<TableHead>Status</TableHead>
|
||
<TableHead className="text-right">Total</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.recentBookings.map((b) => (
|
||
<TableRow key={b.id}>
|
||
<TableCell>
|
||
{b.guest.firstName} {b.guest.lastName}
|
||
</TableCell>
|
||
<TableCell className="text-xs text-muted-foreground">
|
||
{formatDate(b.checkIn)} to {formatDate(b.checkOut)}
|
||
</TableCell>
|
||
<TableCell className="text-xs">
|
||
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary">{b.status}</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right font-medium">
|
||
{formatMoney(b.pricing.total)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
<CardContent className="space-y-3 md:hidden">
|
||
{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-xs text-muted-foreground">
|
||
{formatDate(b.checkIn)} - {b.status}
|
||
</p>
|
||
<p className="mt-1 font-semibold">{formatMoney(b.pricing.total)}</p>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|