diff --git a/src/App.tsx b/src/App.tsx index ab27b73..c5da977 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,9 @@ import { SettingsPage } from "@/pages/SettingsPage"; import { TransactionsPage } from "@/pages/TransactionsPage"; import { VisitsPage } from "@/pages/VisitsPage"; import { ManageUsersPage } from "@/pages/ManageUsersPage"; +import { GuestServicesPage } from "@/pages/GuestServicesPage"; +import { LoyaltyPointsPage } from "@/pages/LoyaltyPointsPage"; +import { HotelRafflesPage } from "@/pages/HotelRafflesPage"; function ProtectedLayout() { const accessToken = useAuthStore((s) => s.accessToken); @@ -46,6 +49,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index e30051d..fd70d5d 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -5,6 +5,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { navFinance, + navGuestLoyalty, navMain, navMarketing, navOther, @@ -59,6 +60,7 @@ export function AppSidebar() { + diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 9f8c027..4bfde97 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -2,13 +2,16 @@ import { BedDouble, CalendarDays, CreditCard, + Gift, LayoutDashboard, Percent, Settings, Share2, + Trophy, Users, Building2, LineChart, + UtensilsCrossed, } from "lucide-react"; export const navMain = [ @@ -19,6 +22,12 @@ export const navMain = [ { 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 = [ { to: "/marketing/visits", label: "Site visits", icon: LineChart }, { to: "/marketing/discount-codes", label: "Discount codes", icon: Percent }, diff --git a/src/lib/hotel-staff-types.ts b/src/lib/hotel-staff-types.ts new file mode 100644 index 0000000..2f1c77f --- /dev/null +++ b/src/lib/hotel-staff-types.ts @@ -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; +}; diff --git a/src/pages/LoyaltyPointsPage.tsx b/src/pages/LoyaltyPointsPage.tsx new file mode 100644 index 0000000..579ad73 --- /dev/null +++ b/src/pages/LoyaltyPointsPage.tsx @@ -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; + +export function LoyaltyPointsPage() { + const { canManageLoyalty, canViewFinanceLedger } = useAuth(); + const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId); + const [catalog, setCatalog] = useState([]); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [edit, setEdit] = useState({}); + const [ledger, setLedger] = useState([]); + const [ledgerLoading, setLedgerLoading] = useState(false); + const [ledgerError, setLedgerError] = useState(null); + + const ruleByActionId = useMemo(() => { + const m = new Map(); + 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("/loyalty/catalog"), + apiGet("/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 ( + + + + ); + } + + return ( + + + Point rules + + Set how many points guests earn per action + + + + + + Actions + + + + + + Code + Label + Points + Enabled + + + + + {catalog.map((a) => { + const e = edit[a.id] ?? { points: "0", isEnabled: true }; + const hasRule = ruleByActionId.has(a.id); + return ( + + {a.code} + {a.label} + + + setEdit((prev) => ({ + ...prev, + [a.id]: { ...e, points: ev.target.value }, + })) + } + /> + + + + + setEdit((prev) => ({ + ...prev, + [a.id]: { ...e, isEnabled: ev.target.checked }, + })) + } + /> + {hasRule ? "On" : "New"} + + + + {canManageLoyalty && ( + void saveRow(a.id)} + > + {savingId === a.id ? "…" : "Save"} + + )} + + + ); + })} + + + {!catalog.length && ( + + No point actions in catalog + + )} + + + + + + Finance: point ledger + {canViewFinanceLedger ? ( + void loadLedger()} + > + {ledgerLoading ? "Refreshing…" : "Refresh"} + + ) : null} + + + {!canViewFinanceLedger ? ( + + Full property ledger is visible to finance and admin roles only. + + ) : ledgerError ? ( + {ledgerError} + ) : ledgerLoading && !ledger.length ? ( + + + + ) : ( + + + + When + Guest + Δ + Reason + + + + {ledger.map((row) => ( + + + {new Date(row.createdAt).toLocaleString()} + + + {row.user?.name ?? row.userId} + {row.user?.email ?? ""} + + {row.delta} + {row.reason} + + ))} + + + )} + {canViewFinanceLedger && !ledgerLoading && !ledger.length && !ledgerError ? ( + No ledger rows yet. + ) : null} + + + + ); +}
+ Set how many points guests earn per action +
+ No point actions in catalog +
+ Full property ledger is visible to finance and admin roles only. +
{ledgerError}
No ledger rows yet.