guest and hotel pages added
This commit is contained in:
parent
2e16450e22
commit
5a47d48467
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user