Compare commits
5 Commits
e7f3709c11
...
cb5ad19655
| Author | SHA1 | Date | |
|---|---|---|---|
| cb5ad19655 | |||
| 5a47d48467 | |||
| 2e16450e22 | |||
| b41d761904 | |||
| 984efd5906 |
|
|
@ -18,6 +18,9 @@ import { SettingsPage } from "@/pages/SettingsPage";
|
||||||
import { TransactionsPage } from "@/pages/TransactionsPage";
|
import { TransactionsPage } from "@/pages/TransactionsPage";
|
||||||
import { VisitsPage } from "@/pages/VisitsPage";
|
import { VisitsPage } from "@/pages/VisitsPage";
|
||||||
import { ManageUsersPage } from "@/pages/ManageUsersPage";
|
import { ManageUsersPage } from "@/pages/ManageUsersPage";
|
||||||
|
import { GuestServicesPage } from "@/pages/GuestServicesPage";
|
||||||
|
import { LoyaltyPointsPage } from "@/pages/LoyaltyPointsPage";
|
||||||
|
import { HotelRafflesPage } from "@/pages/HotelRafflesPage";
|
||||||
|
|
||||||
function ProtectedLayout() {
|
function ProtectedLayout() {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
|
@ -46,6 +49,9 @@ export default function App() {
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/rooms" element={<RoomsPage />} />
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
|
<Route path="/guest-services" element={<GuestServicesPage />} />
|
||||||
|
<Route path="/loyalty/points" element={<LoyaltyPointsPage />} />
|
||||||
|
<Route path="/loyalty/raffles" element={<HotelRafflesPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/transactions" element={<TransactionsPage />} />
|
<Route path="/transactions" element={<TransactionsPage />} />
|
||||||
<Route path="/payments" element={<PaymentsPage />} />
|
<Route path="/payments" element={<PaymentsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
navFinance,
|
navFinance,
|
||||||
|
navGuestLoyalty,
|
||||||
navMain,
|
navMain,
|
||||||
navMarketing,
|
navMarketing,
|
||||||
navOther,
|
navOther,
|
||||||
|
|
@ -59,6 +60,7 @@ export function AppSidebar() {
|
||||||
<ScrollArea className="flex-1 px-3 py-4">
|
<ScrollArea className="flex-1 px-3 py-4">
|
||||||
<nav className="space-y-6">
|
<nav className="space-y-6">
|
||||||
<NavSection title="Main" items={navMain} />
|
<NavSection title="Main" items={navMain} />
|
||||||
|
<NavSection title="Guest & loyalty" items={navGuestLoyalty} />
|
||||||
<NavSection title="Marketing" items={navMarketing} />
|
<NavSection title="Marketing" items={navMarketing} />
|
||||||
<NavSection title="Finance" items={navFinance} />
|
<NavSection title="Finance" items={navFinance} />
|
||||||
<NavSection title="Other" items={navOther} />
|
<NavSection title="Other" items={navOther} />
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import {
|
||||||
BedDouble,
|
BedDouble,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Gift,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Percent,
|
Percent,
|
||||||
Settings,
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
UtensilsCrossed,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const navMain = [
|
export const navMain = [
|
||||||
|
|
@ -19,6 +22,12 @@ export const navMain = [
|
||||||
{ to: "/rooms", label: "Rooms", icon: Building2 },
|
{ to: "/rooms", label: "Rooms", icon: Building2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const navGuestLoyalty = [
|
||||||
|
{ to: "/guest-services", label: "Guest services", icon: UtensilsCrossed },
|
||||||
|
{ to: "/loyalty/points", label: "Point rules", icon: Gift },
|
||||||
|
{ to: "/loyalty/raffles", label: "Raffles", icon: Trophy },
|
||||||
|
];
|
||||||
|
|
||||||
export const navMarketing = [
|
export const navMarketing = [
|
||||||
{ to: "/marketing/visits", label: "Site visits", icon: LineChart },
|
{ to: "/marketing/visits", label: "Site visits", icon: LineChart },
|
||||||
{ to: "/marketing/discount-codes", label: "Discount codes", icon: Percent },
|
{ to: "/marketing/discount-codes", label: "Discount codes", icon: Percent },
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ interface AuthContextValue {
|
||||||
canManageCodes: boolean;
|
canManageCodes: boolean;
|
||||||
canRefund: boolean;
|
canRefund: boolean;
|
||||||
canEditBookings: boolean;
|
canEditBookings: boolean;
|
||||||
|
/** Point rules, raffles, menu — hotel OPERATE (not viewer-only). */
|
||||||
|
canManageLoyalty: boolean;
|
||||||
|
/** Full property point ledger (finance / admin). */
|
||||||
|
canViewFinanceLedger: boolean;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
bootstrapped: boolean;
|
bootstrapped: boolean;
|
||||||
hasHotelProperty: boolean;
|
hasHotelProperty: boolean;
|
||||||
|
|
@ -64,6 +68,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
canRefund: role === "finance" || role === "ADMIN",
|
canRefund: role === "finance" || role === "ADMIN",
|
||||||
canEditBookings:
|
canEditBookings:
|
||||||
role === "front_desk" || role === "finance" || role === "ADMIN",
|
role === "front_desk" || role === "finance" || role === "ADMIN",
|
||||||
|
canManageLoyalty:
|
||||||
|
role === "front_desk" || role === "finance" || role === "ADMIN",
|
||||||
|
canViewFinanceLedger: role === "finance" || role === "ADMIN",
|
||||||
accessToken,
|
accessToken,
|
||||||
bootstrapped,
|
bootstrapped,
|
||||||
hasHotelProperty: properties.length > 0,
|
hasHotelProperty: properties.length > 0,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ function shouldRewriteForHotel(path: string): boolean {
|
||||||
const first = path.split('?')[0];
|
const first = path.split('?')[0];
|
||||||
if (first.startsWith('/auth')) return false;
|
if (first.startsWith('/auth')) return false;
|
||||||
if (first.startsWith('/properties')) return false;
|
if (first.startsWith('/properties')) return false;
|
||||||
|
if (first.startsWith('/admin')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function shouldRewriteForHotel(path: string): boolean {
|
||||||
const first = path.split("?")[0];
|
const first = path.split("?")[0];
|
||||||
if (first.startsWith("/auth")) return false;
|
if (first.startsWith("/auth")) return false;
|
||||||
if (first.startsWith("/properties")) return false;
|
if (first.startsWith("/properties")) return false;
|
||||||
|
if (first.startsWith("/admin")) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +90,26 @@ export async function postLoginPhoneVerify(loginRequestToken: string, otp: strin
|
||||||
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
|
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postSendOtp(identifier: string) {
|
||||||
|
const res = await fetch(apiUrl("/auth/sendOtp"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identifier: identifier.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postHotelGuestLoginEmailOtp(email: string, otp: string) {
|
||||||
|
const res = await fetch(apiUrl("/auth/hotel-user/login-email-otp"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: email.trim().toLowerCase(), otp: otp.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
|
||||||
|
}
|
||||||
|
|
||||||
export type PropertyRow = {
|
export type PropertyRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
61
src/lib/hotel-staff-types.ts
Normal file
61
src/lib/hotel-staff-types.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/** Backend-aligned types for hotel staff UI */
|
||||||
|
|
||||||
|
export type HotelPointAction = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotelPointRule = {
|
||||||
|
id: string;
|
||||||
|
propertyId: string;
|
||||||
|
actionId: string;
|
||||||
|
points: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
action: HotelPointAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotelRaffleRow = {
|
||||||
|
id: string;
|
||||||
|
propertyId: string;
|
||||||
|
prizeDescription: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
drawMode: "RANDOM" | "MIN_POINTS";
|
||||||
|
minPoints: number | null;
|
||||||
|
pointsDeductOnWin: number;
|
||||||
|
maxWinners: number;
|
||||||
|
/** When true, winners are emailed after the draw (persisted as `notifyOnCreate` in API responses). */
|
||||||
|
notifyOnCreate: boolean;
|
||||||
|
status: string;
|
||||||
|
_count?: { entries: number; winners: number };
|
||||||
|
eligiblePoolCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotelLedgerRow = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
propertyId: string;
|
||||||
|
delta: number;
|
||||||
|
reason: string;
|
||||||
|
createdAt: string;
|
||||||
|
sourceKey?: string | null;
|
||||||
|
user?: { id: string; name: string; email?: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BookingTrendDay = {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
cancelled: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RatingSnapshot = {
|
||||||
|
averageRating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
discountUses: number;
|
||||||
|
referralBookings: number;
|
||||||
|
};
|
||||||
|
|
@ -188,7 +188,10 @@ export interface DashboardPayload {
|
||||||
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
||||||
revenueExtras: { label: string; current: number; target: number }[];
|
revenueExtras: { label: string; current: number; target: number }[];
|
||||||
rating: { score: number; label: string; imageUrl?: string };
|
rating: { score: number; label: string; imageUrl?: string };
|
||||||
};
|
recentBookings: Booking[];
|
||||||
|
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
||||||
|
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
||||||
|
}
|
||||||
|
|
||||||
export enum HotelStaffRole {
|
export enum HotelStaffRole {
|
||||||
FRONT_DESK = "FRONT_DESK",
|
FRONT_DESK = "FRONT_DESK",
|
||||||
|
|
@ -213,9 +216,3 @@ export interface StaffAccess {
|
||||||
};
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardPayload {
|
|
||||||
recentBookings: Booking[];
|
|
||||||
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
|
||||||
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { addDays, format, parseISO } from "date-fns";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|
@ -14,6 +15,8 @@ import {
|
||||||
|
|
||||||
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 { Progress } from "@/components/ui/progress";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -22,13 +25,18 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
isLikelyApiHotelBooking,
|
||||||
|
isLikelyApiHotelRoom,
|
||||||
|
mapApiBookingToBooking,
|
||||||
|
mapApiRoomToRoom,
|
||||||
|
} from "@/lib/hotel-adapters";
|
||||||
import { formatDate, formatMoney } from "@/lib/format";
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
|
import type { BookingTrendDay, RatingSnapshot } from "@/lib/hotel-staff-types";
|
||||||
import { roomDisplayName } from "@/lib/room-utils";
|
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 { useAuthStore } from "@/store/authStore";
|
||||||
import type { Booking, DashboardPayload } from "@/lib/types";
|
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: "var(--navy)",
|
backgroundColor: "var(--navy)",
|
||||||
|
|
@ -37,119 +45,239 @@ const tooltipStyle = {
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
};
|
};
|
||||||
|
|
||||||
type HotelSummaryResponse = {
|
type VisitSeriesResponse = {
|
||||||
arrivalsToday?: number;
|
series: Array<{ date: string; count: number }>;
|
||||||
arrivals?: number;
|
|
||||||
departuresToday?: number;
|
|
||||||
departures?: number;
|
|
||||||
unpaidHolds?: number;
|
|
||||||
revenueMonth?: string | number;
|
|
||||||
bookingsByStatus?: Record<string, number>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDashboardPayload(d: unknown): d is DashboardPayload {
|
type ExtraRevenues = {
|
||||||
return typeof d === "object" && d !== null && "bookingSeries" in d;
|
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 {
|
function ratingLabel(score: number): string {
|
||||||
if (typeof d !== "object" || d === null) return false;
|
if (score >= 4.5) return "Impressive";
|
||||||
const x = d as HotelSummaryResponse;
|
if (score >= 3.5) return "Solid";
|
||||||
return (
|
if (score >= 2.5) return "Fair";
|
||||||
typeof x.arrivalsToday === "number" ||
|
return "Growing";
|
||||||
typeof x.arrivals === "number" ||
|
}
|
||||||
x.bookingsByStatus !== undefined
|
|
||||||
);
|
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() {
|
export function DashboardPage() {
|
||||||
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [data, setData] = useState<DashboardPayload | HotelSummaryResponse | null>(
|
const [data, setData] = useState<DashboardState | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErr(null);
|
let active = true;
|
||||||
setData(null);
|
|
||||||
apiGet<DashboardPayload | HotelSummaryResponse>(`/dashboard/summary`)
|
async function load() {
|
||||||
.then(setData)
|
setErr(null);
|
||||||
.catch((e) => setErr(String(e)));
|
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]);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
if (err) return <p className="text-destructive">{err}</p>;
|
if (err) return <p className="text-destructive">{err}</p>;
|
||||||
if (!data) return (
|
if (!data) {
|
||||||
<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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
<div>
|
<Spinner size={32} />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDashboardPayload(data)) {
|
const bookingChartData =
|
||||||
return (
|
data.bookingTrends.length > 0
|
||||||
<p className="text-muted-foreground">
|
? data.bookingTrends.map((d) => ({
|
||||||
Unexpected dashboard response. Try again or check the API.
|
date: chartDayLabel(d.date),
|
||||||
</p>
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">Bookings, visits, and property snapshot</p>
|
||||||
Bookings, visits, and revenue snapshot
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
|
@ -160,7 +288,7 @@ export function DashboardPage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[240px] w-full min-h-[220px]">
|
<div className="h-[240px] w-full min-h-[220px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data.bookingSeries}>
|
<LineChart data={bookingChartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
<YAxis tick={{ fontSize: 11 }} />
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
|
@ -226,11 +354,11 @@ export function DashboardPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{data.heatmap.map((h) => (
|
{data.heatmap.map((h, index) => (
|
||||||
<div
|
<div
|
||||||
key={h.roomId}
|
key={`${h.roomId}-${index}`}
|
||||||
title={h.roomId}
|
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={{
|
style={{
|
||||||
background:
|
background:
|
||||||
h.state === "occupied"
|
h.state === "occupied"
|
||||||
|
|
@ -243,18 +371,16 @@ export function DashboardPage() {
|
||||||
color: h.state === "occupied" ? "#fff" : "inherit",
|
color: h.state === "occupied" ? "#fff" : "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{h.roomId.slice(-2)}
|
{shortRoomLabel(h.roomId, index)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="size-2 rounded-full bg-[var(--chart-5)]" />{" "}
|
<span className="size-2 rounded-full bg-[var(--chart-5)]" /> Vacant
|
||||||
Vacant
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="size-2 rounded-full bg-[var(--chart-1)]" />{" "}
|
<span className="size-2 rounded-full bg-[var(--chart-1)]" /> Occupied
|
||||||
Occupied
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="size-2 rounded-full bg-muted" /> Not ready
|
<span className="size-2 rounded-full bg-muted" /> Not ready
|
||||||
|
|
@ -263,29 +389,35 @@ export function DashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-2xl overflow-hidden">
|
<Card className="overflow-hidden rounded-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Guest rating</CardTitle>
|
<CardTitle className="text-base">Guest rating</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<p className="text-4xl font-bold">{data.rating.score}</p>
|
<p className="text-4xl font-bold">{ratingDisplay}</p>
|
||||||
<p className="text-sm text-muted-foreground">{data.rating.label}</p>
|
<p className="text-sm text-muted-foreground">{ratingSub}</p>
|
||||||
{data.rating.imageUrl && (
|
<img
|
||||||
<img
|
src="https://images.unsplash.com/photo-1566073771259-6a8506099945?auto=format&fit=crop&w=1200&q=80"
|
||||||
src={data.rating.imageUrl}
|
alt="Hotel preview"
|
||||||
alt=""
|
className="mt-2 h-28 w-full rounded-xl object-cover"
|
||||||
className="mt-2 h-28 w-full rounded-xl object-cover"
|
/>
|
||||||
/>
|
<div className="flex flex-wrap gap-4 pt-2 text-sm">
|
||||||
)}
|
|
||||||
<div className="flex gap-4 pt-2 text-sm">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Discount uses</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Referrals</p>
|
<p className="text-muted-foreground">Referral bookings</p>
|
||||||
<p className="font-semibold">{data.codeStats.referralRedemptions}</p>
|
<p className="font-semibold">{data.rating?.referralBookings ?? "—"}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -297,17 +429,18 @@ export function DashboardPage() {
|
||||||
<CardTitle className="text-base">Extra revenue</CardTitle>
|
<CardTitle className="text-base">Extra revenue</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 key={r.label}>
|
||||||
<div className="mb-1 flex justify-between text-sm">
|
<div className="mb-1 flex justify-between text-sm">
|
||||||
<span>{r.label}</span>
|
<span>{r.label}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{formatMoney(r.current)} / {formatMoney(r.target)}
|
{formatMoney(r.value, "ETB")} (30d)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress value={Math.min(100, r.pct)} />
|
||||||
value={Math.min(100, (r.current / r.target) * 100)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -318,15 +451,18 @@ export function DashboardPage() {
|
||||||
<CardTitle className="text-base">Upcoming</CardTitle>
|
<CardTitle className="text-base">Upcoming</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<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
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="rounded-xl border-l-4 border-l-primary bg-muted/30 px-3 py-2 text-sm"
|
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="font-medium">{e.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{formatDate(e.date)}</p>
|
||||||
{formatDate(e.date)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -349,13 +485,13 @@ export function DashboardPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.recentBookings.map((b: Booking) => (
|
{data.recentBookings.map((b) => (
|
||||||
<TableRow key={b.id}>
|
<TableRow key={b.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{b.guest.firstName} {b.guest.lastName}
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-xs">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
{formatDate(b.checkIn)} to {formatDate(b.checkOut)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
|
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
|
||||||
|
|
@ -372,17 +508,15 @@ export function DashboardPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="space-y-3 md:hidden">
|
<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">
|
<div key={b.id} className="rounded-xl border p-3 text-sm">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{b.guest.firstName} {b.guest.lastName}
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDate(b.checkIn)} — {b.status}
|
{formatDate(b.checkIn)} - {b.status}
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-semibold">
|
|
||||||
{formatMoney(b.pricing.total)}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 font-semibold">{formatMoney(b.pricing.total)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
558
src/pages/GuestServicesPage.tsx
Normal file
558
src/pages/GuestServicesPage.tsx
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||||
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
|
|
||||||
|
const MENU_CATS = ["FOOD", "BEVERAGE", "EXTRA"] as const;
|
||||||
|
const OFFER_KINDS = ["SPA_SESSION", "SPA_PACKAGE", "GYM_PASS"] as const;
|
||||||
|
|
||||||
|
const RS_STATUS = [
|
||||||
|
"PENDING",
|
||||||
|
"PREPARING",
|
||||||
|
"OUT_FOR_DELIVERY",
|
||||||
|
"DELIVERED",
|
||||||
|
"CANCELLED",
|
||||||
|
] as const;
|
||||||
|
const LAUNDRY_STATUS = [
|
||||||
|
"REQUESTED",
|
||||||
|
"PICKED_UP",
|
||||||
|
"PROCESSING",
|
||||||
|
"DELIVERED",
|
||||||
|
"CANCELLED",
|
||||||
|
] as const;
|
||||||
|
const SPA_STATUS = ["PENDING", "CONFIRMED", "COMPLETED", "CANCELLED"] as const;
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
unitPrice: string;
|
||||||
|
isAvailable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RsOrder = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
total: string;
|
||||||
|
currency?: string;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { name: string; email?: string | null };
|
||||||
|
booking?: {
|
||||||
|
room?: { name: string };
|
||||||
|
customer?: { firstName: string; lastName: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaundryRow = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { name: string };
|
||||||
|
booking?: { room?: { name: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Offering = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpaBookingRow = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
total: string;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { name: string };
|
||||||
|
offering?: { name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GuestServicesPage() {
|
||||||
|
const { canManageLoyalty, canEditBookings } = useAuth();
|
||||||
|
const canOps = canManageLoyalty && canEditBookings;
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
|
||||||
|
const [menu, setMenu] = useState<MenuItem[]>([]);
|
||||||
|
const [rs, setRs] = useState<RsOrder[]>([]);
|
||||||
|
const [laundry, setLaundry] = useState<LaundryRow[]>([]);
|
||||||
|
const [offerings, setOfferings] = useState<Offering[]>([]);
|
||||||
|
const [spaBookings, setSpaBookings] = useState<SpaBookingRow[]>([]);
|
||||||
|
const [tab, setTab] = useState("menu");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [mOpen, setMOpen] = useState(false);
|
||||||
|
const [mName, setMName] = useState("");
|
||||||
|
const [mCat, setMCat] = useState<(typeof MENU_CATS)[number]>("FOOD");
|
||||||
|
const [mPrice, setMPrice] = useState("0");
|
||||||
|
|
||||||
|
const [oOpen, setOOpen] = useState(false);
|
||||||
|
const [oKind, setOKind] = useState<(typeof OFFER_KINDS)[number]>("SPA_SESSION");
|
||||||
|
const [oName, setOName] = useState("");
|
||||||
|
const [oPrice, setOPrice] = useState("0");
|
||||||
|
|
||||||
|
const loadMenu = useCallback(async () => {
|
||||||
|
const r = await apiGet<{ data: MenuItem[] }>("/menu/items");
|
||||||
|
setMenu(r.data);
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
const loadRs = useCallback(async () => {
|
||||||
|
const r = await apiGet<{ data: RsOrder[] }>("/room-service/orders");
|
||||||
|
setRs(r.data);
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
const loadLaundry = useCallback(async () => {
|
||||||
|
const r = await apiGet<{ data: LaundryRow[] }>("/laundry/requests");
|
||||||
|
setLaundry(r.data);
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
const loadSpa = useCallback(async () => {
|
||||||
|
const [o, b] = await Promise.all([
|
||||||
|
apiGet<{ data: Offering[] }>("/spa/offerings"),
|
||||||
|
apiGet<{ data: SpaBookingRow[] }>("/spa/bookings"),
|
||||||
|
]);
|
||||||
|
setOfferings(o.data);
|
||||||
|
setSpaBookings(b.data);
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([loadMenu(), loadRs(), loadLaundry(), loadSpa()]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadMenu, loadRs, loadLaundry, loadSpa]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
async function addMenu(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await apiPost("/menu/items", {
|
||||||
|
name: mName,
|
||||||
|
category: mCat,
|
||||||
|
unitPrice: mPrice,
|
||||||
|
isAvailable: true,
|
||||||
|
});
|
||||||
|
setMOpen(false);
|
||||||
|
setMName("");
|
||||||
|
setMPrice("0");
|
||||||
|
void loadMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOffering(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await apiPost("/spa/offerings", {
|
||||||
|
kind: oKind,
|
||||||
|
name: oName,
|
||||||
|
price: oPrice,
|
||||||
|
});
|
||||||
|
setOOpen(false);
|
||||||
|
setOName("");
|
||||||
|
setOPrice("0");
|
||||||
|
void loadSpa();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[320px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Guest services</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList className="flex flex-wrap h-auto gap-1">
|
||||||
|
<TabsTrigger value="menu">Menu</TabsTrigger>
|
||||||
|
<TabsTrigger value="rs">Room service</TabsTrigger>
|
||||||
|
<TabsTrigger value="laundry">Laundry</TabsTrigger>
|
||||||
|
<TabsTrigger value="spa">Spa & gym</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="menu" className="mt-4">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Menu items</CardTitle>
|
||||||
|
{canOps && (
|
||||||
|
<Dialog open={mOpen} onOpenChange={setMOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">Add item</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New menu item</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={addMenu} className="grid gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={mName}
|
||||||
|
onChange={(e) => setMName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Select
|
||||||
|
value={mCat}
|
||||||
|
onValueChange={(v) => setMCat(v as (typeof MENU_CATS)[number])}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MENU_CATS.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Unit price (ETB)</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={mPrice}
|
||||||
|
onChange={(e) => setMPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Price</TableHead>
|
||||||
|
<TableHead>Available</TableHead>
|
||||||
|
{canOps && <TableHead />}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{menu.map((it) => (
|
||||||
|
<TableRow key={it.id}>
|
||||||
|
<TableCell>{it.name}</TableCell>
|
||||||
|
<TableCell className="text-xs">{it.category}</TableCell>
|
||||||
|
<TableCell>{formatMoney(Number(it.unitPrice), "ETB")}</TableCell>
|
||||||
|
<TableCell>{it.isAvailable ? "Yes" : "No"}</TableCell>
|
||||||
|
{canOps && (
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
void apiPatch(`/menu/items/${it.id}`, {
|
||||||
|
isAvailable: !it.isAvailable,
|
||||||
|
}).then(() => loadMenu())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Toggle
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="rs" className="mt-4">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Room service orders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Room</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rs.map((o) => (
|
||||||
|
<TableRow key={o.id}>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{o.user?.name ?? "—"}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(o.createdAt)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{o.booking?.room?.name ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canOps ? (
|
||||||
|
<Select
|
||||||
|
value={o.status}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
void apiPatch(`/room-service/orders/${o.id}`, {
|
||||||
|
status: v,
|
||||||
|
}).then(() => loadRs())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RS_STATUS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">{o.status}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatMoney(Number(o.total), o.currency ?? "ETB")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="laundry" className="mt-4">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Laundry</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Room</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{laundry.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="text-sm">{r.user?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.booking?.room?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canOps ? (
|
||||||
|
<Select
|
||||||
|
value={r.status}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
void apiPatch(`/laundry/requests/${r.id}`, {
|
||||||
|
status: v,
|
||||||
|
}).then(() => loadLaundry())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LAUNDRY_STATUS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">{r.status}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="spa" className="mt-4 space-y-4">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Offerings</CardTitle>
|
||||||
|
{canOps && (
|
||||||
|
<Dialog open={oOpen} onOpenChange={setOOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">Add offering</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New offering</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={addOffering} className="grid gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Kind</Label>
|
||||||
|
<Select
|
||||||
|
value={oKind}
|
||||||
|
onValueChange={(v) => setOKind(v as (typeof OFFER_KINDS)[number])}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OFFER_KINDS.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{k}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={oName}
|
||||||
|
onChange={(e) => setOName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Price (ETB)</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={oPrice}
|
||||||
|
onChange={(e) => setOPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Kind</TableHead>
|
||||||
|
<TableHead>Price</TableHead>
|
||||||
|
<TableHead>Active</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{offerings.map((x) => (
|
||||||
|
<TableRow key={x.id}>
|
||||||
|
<TableCell>{x.name}</TableCell>
|
||||||
|
<TableCell className="text-xs">{x.kind}</TableCell>
|
||||||
|
<TableCell>{formatMoney(Number(x.price), "ETB")}</TableCell>
|
||||||
|
<TableCell>{x.isActive ? "Yes" : "No"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Bookings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Offering</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{spaBookings.map((b) => (
|
||||||
|
<TableRow key={b.id}>
|
||||||
|
<TableCell className="text-sm">{b.user?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{b.offering?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canOps ? (
|
||||||
|
<Select
|
||||||
|
value={b.status}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
void apiPatch(`/spa/bookings/${b.id}`, {
|
||||||
|
status: v,
|
||||||
|
}).then(() => loadSpa())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SPA_STATUS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">{b.status}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatMoney(Number(b.total), "ETB")}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
341
src/pages/HotelRafflesPage.tsx
Normal file
341
src/pages/HotelRafflesPage.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type { HotelRaffleRow } from "@/lib/hotel-staff-types";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type ParticipantEntry = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: { id: string; name: string; email?: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
type EligibleGuestRow = {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
user: { id: string; name: string; email?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HotelRafflesPage() {
|
||||||
|
const { canManageLoyalty } = useAuth();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
const [rows, setRows] = useState<HotelRaffleRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [participants, setParticipants] = useState<{
|
||||||
|
raffleId: string;
|
||||||
|
entries: ParticipantEntry[];
|
||||||
|
eligiblePool: EligibleGuestRow[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [prizeDescription, setPrizeDescription] = useState("");
|
||||||
|
const [startsAt, setStartsAt] = useState("");
|
||||||
|
const [endsAt, setEndsAt] = useState("");
|
||||||
|
const [drawMode, setDrawMode] = useState<"RANDOM" | "MIN_POINTS">("RANDOM");
|
||||||
|
const [minPoints, setMinPoints] = useState("0");
|
||||||
|
const [maxWinners, setMaxWinners] = useState("3");
|
||||||
|
const [emailWinnersOnWin, setEmailWinnersOnWin] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await apiGet<HotelRaffleRow[]>("/raffles");
|
||||||
|
setRows(r);
|
||||||
|
} catch {
|
||||||
|
setRows([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function createRaffle(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!startsAt || !endsAt) return;
|
||||||
|
const min = Math.max(0, parseInt(minPoints, 10) || 0);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await apiPost("/raffles", {
|
||||||
|
prizeDescription,
|
||||||
|
startsAt: new Date(startsAt).toISOString(),
|
||||||
|
endsAt: new Date(endsAt).toISOString(),
|
||||||
|
drawMode,
|
||||||
|
minPoints: drawMode === "MIN_POINTS" ? min : min > 0 ? min : undefined,
|
||||||
|
maxWinners: Math.min(10, Math.max(1, parseInt(maxWinners, 10) || 1)),
|
||||||
|
emailWinnersOnWin,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setPrizeDescription("");
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openParticipants(raffleId: string) {
|
||||||
|
const res = await apiGet<{
|
||||||
|
entries: ParticipantEntry[];
|
||||||
|
eligiblePool: EligibleGuestRow[];
|
||||||
|
}>(`/raffles/${raffleId}/participants`);
|
||||||
|
setParticipants({
|
||||||
|
raffleId,
|
||||||
|
entries: res.entries ?? [],
|
||||||
|
eligiblePool: res.eligiblePool ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[320px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Raffles</h1>
|
||||||
|
</div>
|
||||||
|
{canManageLoyalty && (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>New raffle</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create raffle</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={createRaffle} className="grid gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Prize (text)</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={prizeDescription}
|
||||||
|
onChange={(e) => setPrizeDescription(e.target.value)}
|
||||||
|
placeholder="Free spa night, weekend upgrade…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Starts</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
value={startsAt}
|
||||||
|
onChange={(e) => setStartsAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Ends</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
value={endsAt}
|
||||||
|
onChange={(e) => setEndsAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Draw mode</Label>
|
||||||
|
<Select
|
||||||
|
value={drawMode}
|
||||||
|
onValueChange={(v) => setDrawMode(v as "RANDOM" | "MIN_POINTS")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="RANDOM">
|
||||||
|
Random — min balance to be in pool; no point deduction
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MIN_POINTS">
|
||||||
|
Min points — balance ≥ threshold; deduct that many points from each winner
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{drawMode === "RANDOM"
|
||||||
|
? "Minimum points (pool eligibility)"
|
||||||
|
: "Minimum points (eligibility & deduction per winner)"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={minPoints}
|
||||||
|
onChange={(e) => setMinPoints(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max winners (1–10)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={maxWinners}
|
||||||
|
onChange={(e) => setMaxWinners(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailWinnersOnWin}
|
||||||
|
onChange={(e) => setEmailWinnersOnWin(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Email winners after the draw
|
||||||
|
</label>
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">All raffles</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Prize</TableHead>
|
||||||
|
<TableHead>Window</TableHead>
|
||||||
|
<TableHead>Mode</TableHead>
|
||||||
|
<TableHead>Points</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Pool / winners</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-sm">
|
||||||
|
{r.prizeDescription}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(r.startsAt)} → {formatDateTime(r.endsAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.drawMode}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
min {r.minPoints ?? 0}
|
||||||
|
{r.drawMode === "MIN_POINTS" ? ` · deduct ${r.pointsDeductOnWin}` : ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.status}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
pool {r.eligiblePoolCount ?? "—"} · winners {r._count?.winners ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void openParticipants(r.id)}>
|
||||||
|
Pool & records
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{!rows.length && (
|
||||||
|
<p className="py-8 text-center text-muted-foreground text-sm">No raffles yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!participants} onOpenChange={(o) => !o && setParticipants(null)}>
|
||||||
|
<DialogContent className="max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Raffle pool & draw records</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Eligible guests are all linked profiles at this property. After the draw, entry rows are
|
||||||
|
created for winners only.
|
||||||
|
</p>
|
||||||
|
<h4 className="text-sm font-medium pt-2">Eligible pool (linked guests)</h4>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(participants?.eligiblePool ?? []).map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>{row.user?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{row.user?.email ?? "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{!participants?.eligiblePool?.length && (
|
||||||
|
<p className="text-xs text-muted-foreground py-2">No linked guest accounts yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium pt-4">Winner entries (after draw)</h4>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Recorded</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(participants?.entries ?? []).map((en) => (
|
||||||
|
<TableRow key={en.id}>
|
||||||
|
<TableCell>{en.user.name}</TableCell>
|
||||||
|
<TableCell className="text-xs">{en.user.email ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(en.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{!participants?.entries?.length && (
|
||||||
|
<p className="text-xs text-muted-foreground py-2">No draw records yet (raffle still open or no winners).</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ export function LoginPage() {
|
||||||
const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword);
|
const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword);
|
||||||
const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp);
|
const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp);
|
||||||
const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp);
|
const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp);
|
||||||
|
const requestHotelEmailOtp = useAuthStore((s) => s.requestHotelEmailOtp);
|
||||||
|
const verifyHotelEmailOtp = useAuthStore((s) => s.verifyHotelEmailOtp);
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
@ -28,6 +30,10 @@ export function LoginPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [phoneHint, setPhoneHint] = useState<string | null>(null);
|
const [phoneHint, setPhoneHint] = useState<string | null>(null);
|
||||||
|
const [guestEmail, setGuestEmail] = useState("");
|
||||||
|
const [emailOtp, setEmailOtp] = useState("");
|
||||||
|
const [emailOtpSent, setEmailOtpSent] = useState(false);
|
||||||
|
const [emailHint, setEmailHint] = useState<string | null>(null);
|
||||||
|
|
||||||
async function onEmailLogin(e: React.FormEvent) {
|
async function onEmailLogin(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -65,6 +71,36 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onRequestGuestEmailOtp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setEmailHint(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await requestHotelEmailOtp(guestEmail);
|
||||||
|
setEmailHint(res.message);
|
||||||
|
setEmailOtpSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not send code");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onVerifyGuestEmailOtp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await verifyHotelEmailOtp(guestEmail, emailOtp);
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Invalid code");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onVerifyOtp(e: React.FormEvent) {
|
async function onVerifyOtp(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!loginRequestToken) {
|
if (!loginRequestToken) {
|
||||||
|
|
@ -94,10 +130,13 @@ export function LoginPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="email" className="w-full">
|
<Tabs defaultValue="email" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 rounded-xl">
|
<TabsList className="grid w-full grid-cols-3 rounded-xl">
|
||||||
<TabsTrigger value="email" className="rounded-lg">
|
<TabsTrigger value="email" className="rounded-lg">
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="email-otp" className="rounded-lg">
|
||||||
|
Email code
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="phone" className="rounded-lg">
|
<TabsTrigger value="phone" className="rounded-lg">
|
||||||
Phone
|
Phone
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -141,6 +180,80 @@ export function LoginPage() {
|
||||||
</form>
|
</form>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="email-otp" className="mt-4 space-y-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
For hotel guest accounts linked to this property: request a code to the email on file,
|
||||||
|
then sign in without a password.
|
||||||
|
</p>
|
||||||
|
{!emailOtpSent ? (
|
||||||
|
<form onSubmit={onRequestGuestEmailOtp} className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="guest-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="guest-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={guestEmail}
|
||||||
|
onChange={(e) => setGuestEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emailHint ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{emailHint}</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" className="rounded-xl" disabled={loading}>
|
||||||
|
{loading ? "Sending…" : "Send code"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onVerifyGuestEmailOtp} className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="guest-otp">One-time code</Label>
|
||||||
|
<Input
|
||||||
|
id="guest-otp"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
required
|
||||||
|
minLength={4}
|
||||||
|
value={emailOtp}
|
||||||
|
onChange={(e) => setEmailOtp(e.target.value)}
|
||||||
|
placeholder="Code from email"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" className="rounded-xl" disabled={loading}>
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => {
|
||||||
|
setEmailOtpSent(false);
|
||||||
|
setEmailOtp("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use different email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="phone" className="mt-4 space-y-4">
|
<TabsContent value="phone" className="mt-4 space-y-4">
|
||||||
<form onSubmit={onRequestOtp} className="grid gap-4">
|
<form onSubmit={onRequestOtp} className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
260
src/pages/LoyaltyPointsPage.tsx
Normal file
260
src/pages/LoyaltyPointsPage.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type { HotelLedgerRow, HotelPointAction, HotelPointRule } from "@/lib/hotel-staff-types";
|
||||||
|
|
||||||
|
type RowEdit = Record<string, { points: string; isEnabled: boolean }>;
|
||||||
|
|
||||||
|
export function LoyaltyPointsPage() {
|
||||||
|
const { canManageLoyalty, canViewFinanceLedger } = useAuth();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
const [catalog, setCatalog] = useState<HotelPointAction[]>([]);
|
||||||
|
const [rules, setRules] = useState<HotelPointRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [edit, setEdit] = useState<RowEdit>({});
|
||||||
|
const [ledger, setLedger] = useState<HotelLedgerRow[]>([]);
|
||||||
|
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||||
|
const [ledgerError, setLedgerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const ruleByActionId = useMemo(() => {
|
||||||
|
const m = new Map<string, HotelPointRule>();
|
||||||
|
for (const r of rules) m.set(r.actionId, r);
|
||||||
|
return m;
|
||||||
|
}, [rules]);
|
||||||
|
|
||||||
|
const loadLedger = useCallback(async () => {
|
||||||
|
if (!canViewFinanceLedger) return;
|
||||||
|
setLedgerLoading(true);
|
||||||
|
setLedgerError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiGet<{ data: HotelLedgerRow[] }>("/loyalty/ledger");
|
||||||
|
setLedger(res.data ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setLedger([]);
|
||||||
|
setLedgerError(e instanceof Error ? e.message : "Could not load ledger");
|
||||||
|
} finally {
|
||||||
|
setLedgerLoading(false);
|
||||||
|
}
|
||||||
|
}, [canViewFinanceLedger, selectedPropertyId]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [c, r] = await Promise.all([
|
||||||
|
apiGet<HotelPointAction[]>("/loyalty/catalog"),
|
||||||
|
apiGet<HotelPointRule[]>("/loyalty/rules"),
|
||||||
|
]);
|
||||||
|
setCatalog(c.filter((a) => a.isActive));
|
||||||
|
setRules(r);
|
||||||
|
const next: RowEdit = {};
|
||||||
|
for (const a of c) {
|
||||||
|
const existing = r.find((x) => x.actionId === a.id);
|
||||||
|
next[a.id] = {
|
||||||
|
points: String(existing?.points ?? 0),
|
||||||
|
isEnabled: existing?.isEnabled ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setEdit(next);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLedger();
|
||||||
|
}, [loadLedger]);
|
||||||
|
|
||||||
|
async function saveRow(actionId: string) {
|
||||||
|
if (!canManageLoyalty) return;
|
||||||
|
const row = edit[actionId];
|
||||||
|
if (!row) return;
|
||||||
|
const points = Math.max(0, parseInt(row.points, 10) || 0);
|
||||||
|
setSavingId(actionId);
|
||||||
|
try {
|
||||||
|
await apiPost("/loyalty/rules", {
|
||||||
|
actionId,
|
||||||
|
points,
|
||||||
|
isEnabled: row.isEnabled,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[320px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Point rules</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Set how many points guests earn per action
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead className="w-28">Points</TableHead>
|
||||||
|
<TableHead className="w-32">Enabled</TableHead>
|
||||||
|
<TableHead className="w-28" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{catalog.map((a) => {
|
||||||
|
const e = edit[a.id] ?? { points: "0", isEnabled: true };
|
||||||
|
const hasRule = ruleByActionId.has(a.id);
|
||||||
|
return (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{a.code}</TableCell>
|
||||||
|
<TableCell>{a.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9 w-24"
|
||||||
|
disabled={!canManageLoyalty}
|
||||||
|
value={e.points}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setEdit((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[a.id]: { ...e, points: ev.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!canManageLoyalty}
|
||||||
|
checked={e.isEnabled}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setEdit((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[a.id]: { ...e, isEnabled: ev.target.checked },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{hasRule ? "On" : "New"}
|
||||||
|
</label>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canManageLoyalty && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={savingId === a.id}
|
||||||
|
onClick={() => void saveRow(a.id)}
|
||||||
|
>
|
||||||
|
{savingId === a.id ? "…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{!catalog.length && (
|
||||||
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
No point actions in catalog
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">Finance: point ledger</CardTitle>
|
||||||
|
{canViewFinanceLedger ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={ledgerLoading}
|
||||||
|
onClick={() => void loadLedger()}
|
||||||
|
>
|
||||||
|
{ledgerLoading ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm">
|
||||||
|
{!canViewFinanceLedger ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Full property ledger is visible to finance and admin roles only.
|
||||||
|
</p>
|
||||||
|
) : ledgerError ? (
|
||||||
|
<p className="text-destructive">{ledgerError}</p>
|
||||||
|
) : ledgerLoading && !ledger.length ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner size={28} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead className="text-right">Δ</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ledger.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{new Date(row.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
<div>{row.user?.name ?? row.userId}</div>
|
||||||
|
<div className="text-muted-foreground">{row.user?.email ?? ""}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-xs">{row.delta}</TableCell>
|
||||||
|
<TableCell className="text-xs">{row.reason}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
{canViewFinanceLedger && !ledgerLoading && !ledger.length && !ledgerError ? (
|
||||||
|
<p className="py-6 text-center text-muted-foreground text-sm">No ledger rows yet.</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
|
|
@ -29,6 +30,8 @@ export function VisitsPage() {
|
||||||
>([]);
|
>([]);
|
||||||
const [recent, setRecent] = useState<SiteVisit[]>([]);
|
const [recent, setRecent] = useState<SiteVisit[]>([]);
|
||||||
|
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const v = await apiGet<{
|
const v = await apiGet<{
|
||||||
series: Array<{ date: string; count: number }>;
|
series: Array<{ date: string; count: number }>;
|
||||||
|
|
@ -40,11 +43,11 @@ export function VisitsPage() {
|
||||||
})).slice(-21));
|
})).slice(-21));
|
||||||
const r = await apiGet<{ data: SiteVisit[] }>("/analytics/visits/recent");
|
const r = await apiGet<{ data: SiteVisit[] }>("/analytics/visits/recent");
|
||||||
setRecent(r.data);
|
setRecent(r.data);
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh, selectedPropertyId]);
|
||||||
|
|
||||||
async function simulateHit() {
|
async function simulateHit() {
|
||||||
await apiPost("/analytics/visits", {
|
await apiPost("/analytics/visits", {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { persist } from "zustand/middleware";
|
||||||
import {
|
import {
|
||||||
getProperties,
|
getProperties,
|
||||||
getProfile,
|
getProfile,
|
||||||
|
postHotelGuestLoginEmailOtp,
|
||||||
postLogin,
|
postLogin,
|
||||||
postLoginPhoneRequest,
|
postLoginPhoneRequest,
|
||||||
postLoginPhoneVerify,
|
postLoginPhoneVerify,
|
||||||
|
postSendOtp,
|
||||||
registerHotelAuthApiContext,
|
registerHotelAuthApiContext,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type PropertyRow,
|
type PropertyRow,
|
||||||
|
|
@ -36,6 +38,9 @@ export function mapBackendRoleToAdminRole(role: string): AdminRole {
|
||||||
return "ADMIN";
|
return "ADMIN";
|
||||||
case "PROJECT_MANAGER":
|
case "PROJECT_MANAGER":
|
||||||
return "viewer";
|
return "viewer";
|
||||||
|
case "HOTEL_USER":
|
||||||
|
case "CUSTOMER":
|
||||||
|
return "viewer";
|
||||||
default:
|
default:
|
||||||
return "viewer";
|
return "viewer";
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +67,8 @@ type AuthActions = {
|
||||||
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
|
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||||
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
|
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
|
||||||
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
|
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
|
||||||
|
requestHotelEmailOtp: (email: string) => Promise<{ message: string }>;
|
||||||
|
verifyHotelEmailOtp: (email: string, otp: string) => Promise<void>;
|
||||||
bootstrap: () => Promise<void>;
|
bootstrap: () => Promise<void>;
|
||||||
setSelectedPropertyId: (id: string) => void;
|
setSelectedPropertyId: (id: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|
@ -116,6 +123,15 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||||
await get().setSession(access_token, user);
|
await get().setSession(access_token, user);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestHotelEmailOtp: async (email) => {
|
||||||
|
return postSendOtp(email.trim().toLowerCase());
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyHotelEmailOtp: async (email, otp) => {
|
||||||
|
const { access_token, user } = await postHotelGuestLoginEmailOtp(email, otp);
|
||||||
|
await get().setSession(access_token, user);
|
||||||
|
},
|
||||||
|
|
||||||
bootstrap: async () => {
|
bootstrap: async () => {
|
||||||
const token = get().accessToken;
|
const token = get().accessToken;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user