diff --git a/src/pages/GuestServicesPage.tsx b/src/pages/GuestServicesPage.tsx new file mode 100644 index 0000000..5d25eba --- /dev/null +++ b/src/pages/GuestServicesPage.tsx @@ -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([]); + const [rs, setRs] = useState([]); + const [laundry, setLaundry] = useState([]); + const [offerings, setOfferings] = useState([]); + const [spaBookings, setSpaBookings] = useState([]); + 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 ( +
+ +
+ ); + } + + return ( +
+
+
+

Guest services

+
+ +
+ + + + Menu + Room service + Laundry + Spa & gym + + + + + + Menu items + {canOps && ( + + + + + + + New menu item + +
+
+ + setMName(e.target.value)} + /> +
+
+ + +
+
+ + setMPrice(e.target.value)} + /> +
+ +
+
+
+ )} +
+ + + + + Name + Category + Price + Available + {canOps && } + + + + {menu.map((it) => ( + + {it.name} + {it.category} + {formatMoney(Number(it.unitPrice), "ETB")} + {it.isAvailable ? "Yes" : "No"} + {canOps && ( + + + + )} + + ))} + +
+
+
+
+ + + + + Room service orders + + + + + + Guest + Room + Status + Total + + + + {rs.map((o) => ( + + + {o.user?.name ?? "—"} +
+ {formatDateTime(o.createdAt)} +
+
+ + {o.booking?.room?.name ?? "—"} + + + {canOps ? ( + + ) : ( + {o.status} + )} + + + {formatMoney(Number(o.total), o.currency ?? "ETB")} + +
+ ))} +
+
+
+
+
+ + + + + Laundry + + + + + + Guest + Room + Status + + + + {laundry.map((r) => ( + + {r.user?.name ?? "—"} + {r.booking?.room?.name ?? "—"} + + {canOps ? ( + + ) : ( + {r.status} + )} + + + ))} + +
+
+
+
+ + + + + Offerings + {canOps && ( + + + + + + + New offering + +
+
+ + +
+
+ + setOName(e.target.value)} + /> +
+
+ + setOPrice(e.target.value)} + /> +
+ +
+
+
+ )} +
+ + + + + Name + Kind + Price + Active + + + + {offerings.map((x) => ( + + {x.name} + {x.kind} + {formatMoney(Number(x.price), "ETB")} + {x.isActive ? "Yes" : "No"} + + ))} + +
+
+
+ + + + Bookings + + + + + + Guest + Offering + Status + Total + + + + {spaBookings.map((b) => ( + + {b.user?.name ?? "—"} + {b.offering?.name ?? "—"} + + {canOps ? ( + + ) : ( + {b.status} + )} + + {formatMoney(Number(b.total), "ETB")} + + ))} + +
+
+
+
+
+
+ ); +} diff --git a/src/pages/HotelRafflesPage.tsx b/src/pages/HotelRafflesPage.tsx new file mode 100644 index 0000000..89c2d03 --- /dev/null +++ b/src/pages/HotelRafflesPage.tsx @@ -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([]); + 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("/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 ( +
+ +
+ ); + } + + return ( +
+
+
+

Raffles

+
+ {canManageLoyalty && ( + + + + + + + Create raffle + +
+
+ + setPrizeDescription(e.target.value)} + placeholder="Free spa night, weekend upgrade…" + /> +
+
+
+ + setStartsAt(e.target.value)} + /> +
+
+ + setEndsAt(e.target.value)} + /> +
+
+
+ + +
+
+ + setMinPoints(e.target.value)} + /> +
+
+ + setMaxWinners(e.target.value)} + /> +
+ + +
+
+
+ )} +
+ + + + All raffles + + + + + + Prize + Window + Mode + Points + Status + Pool / winners + + + + + {rows.map((r) => ( + + + {r.prizeDescription} + + + {formatDateTime(r.startsAt)} → {formatDateTime(r.endsAt)} + + {r.drawMode} + + min {r.minPoints ?? 0} + {r.drawMode === "MIN_POINTS" ? ` · deduct ${r.pointsDeductOnWin}` : ""} + + {r.status} + + pool {r.eligiblePoolCount ?? "—"} · winners {r._count?.winners ?? "—"} + + + + + + ))} + +
+ {!rows.length && ( +

No raffles yet.

+ )} +
+
+ + !o && setParticipants(null)}> + + + Raffle pool & draw records + +

+ Eligible guests are all linked profiles at this property. After the draw, entry rows are + created for winners only. +

+

Eligible pool (linked guests)

+ + + + Guest + Email + + + + {(participants?.eligiblePool ?? []).map((row) => ( + + {row.user?.name ?? "—"} + {row.user?.email ?? "—"} + + ))} + +
+ {!participants?.eligiblePool?.length && ( +

No linked guest accounts yet.

+ )} + +

Winner entries (after draw)

+ + + + Guest + Email + Recorded + + + + {(participants?.entries ?? []).map((en) => ( + + {en.user.name} + {en.user.email ?? "—"} + + {formatDateTime(en.createdAt)} + + + ))} + +
+ {!participants?.entries?.length && ( +

No draw records yet (raffle still open or no winners).

+ )} +
+
+
+ ); +}