Yaltopia-Hotels/src/pages/GuestServicesPage.tsx

559 lines
19 KiB
TypeScript

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