Yaltopia-Hotels/src/pages/DashboardPage.tsx
2026-04-07 12:36:36 +03:00

527 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}