Yaltopia-Hotels/src/pages/RoomsPage.tsx

366 lines
12 KiB
TypeScript

import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
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 { FileUploadField } from "@/components/ui/file-upload-field";
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 { apiGet, apiPatch, apiPost } from "@/lib/api";
import { Spinner } from "@/components/ui/spinner";
import {
isLikelyApiHotelRoom,
mapApiRoomToRoom,
} from "@/lib/hotel-adapters";
import { ROOM_CATALOGUE } from "@/lib/constants";
import type { Room } from "@/lib/types";
import { formatMoney } from "@/lib/format";
import { useAuthStore } from "@/store/authStore";
export function RoomsPage() {
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editingRoomId, setEditingRoomId] = useState<string | null>(null);
const [name, setName] = useState("");
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
const [maxGuests, setMaxGuests] = useState("2");
const [baseRate, setBaseRate] = useState("120");
const [imageFiles, setImageFiles] = useState<File[]>([]);
function load() {
setLoading(true);
apiGet<{ data: unknown[] }>("/rooms")
.then((r) => {
const mapped = r.data.map((row) =>
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
);
setRooms(mapped);
})
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, [selectedPropertyId]);
function resetForm() {
setEditingRoomId(null);
setName("");
setSlug(ROOM_CATALOGUE[0].slug);
setMaxGuests("2");
setBaseRate("120");
setImageFiles([]);
}
async function addRoom(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
const formData = new FormData();
formData.append("name", name);
formData.append("roomType", slug);
formData.append("maxGuests", maxGuests);
formData.append("baseRate", baseRate);
imageFiles.forEach((file) => formData.append("images", file));
await apiPost<Room>("/rooms", formData);
setOpen(false);
resetForm();
load();
} finally {
setSubmitting(false);
}
}
function startEdit(room: Room) {
setEditingRoomId(room.id);
setName(room.name);
setSlug(room.roomTypeSlug);
setMaxGuests(String(room.maxGuests));
setBaseRate(String(room.baseRate));
setImageFiles([]);
setEditOpen(true);
}
async function updateRoom(e: React.FormEvent) {
e.preventDefault();
if (!editingRoomId) return;
setSubmitting(true);
try {
const formData = new FormData();
formData.append("name", name);
formData.append("roomType", slug);
formData.append("maxGuests", maxGuests);
formData.append("baseRate", baseRate);
imageFiles.forEach((file) => formData.append("images", file));
await apiPatch<Room>(`/rooms/${editingRoomId}`, formData);
setEditOpen(false);
resetForm();
load();
} finally {
setSubmitting(false);
}
}
if (loading && rooms.length === 0)
return (
<div className="flex min-h-[400px] items-center justify-center">
<Spinner size={32} />
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Rooms</h1>
<div className="flex items-center gap-2">
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) resetForm();
}}
>
<DialogTrigger asChild>
<Button>+ Add room</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add room</DialogTitle>
</DialogHeader>
<form onSubmit={addRoom} className="grid gap-3">
<div className="space-y-2">
<Label>Unit name</Label>
<Input
required
placeholder="Room 104"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={slug} onValueChange={setSlug}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROOM_CATALOGUE.map((r) => (
<SelectItem key={r.slug} value={r.slug}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Max guests</Label>
<Input
type="number"
value={maxGuests}
onChange={(e) => setMaxGuests(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Base rate</Label>
<Input
type="number"
value={baseRate}
onChange={(e) => setBaseRate(e.target.value)}
/>
</div>
</div>
<FileUploadField
id="room-images"
label="Images"
multiple
required
files={imageFiles}
buttonLabel="Upload images"
emptyLabel="Select room images"
helperText="Add one or more room photos. This is required when creating a room."
onChange={(e) => setImageFiles(Array.from(e.target.files ?? []))}
/>
<Button type="submit" loading={submitting}>
Save
</Button>
</form>
</DialogContent>
</Dialog>
<Dialog
open={editOpen}
onOpenChange={(next) => {
setEditOpen(next);
if (!next) resetForm();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit room</DialogTitle>
</DialogHeader>
<form onSubmit={updateRoom} className="grid gap-3">
<div className="space-y-2">
<Label>Unit name</Label>
<Input
required
placeholder="Room 104"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={slug} onValueChange={setSlug}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROOM_CATALOGUE.map((r) => (
<SelectItem key={r.slug} value={r.slug}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Max guests</Label>
<Input
type="number"
value={maxGuests}
onChange={(e) => setMaxGuests(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Base rate</Label>
<Input
type="number"
value={baseRate}
onChange={(e) => setBaseRate(e.target.value)}
/>
</div>
</div>
<FileUploadField
id="room-images-edit"
label="Replace images"
multiple
files={imageFiles}
buttonLabel="Choose replacements"
emptyLabel="Keep current room images"
helperText="Leave this empty to keep the current images, or upload new files to replace them."
onChange={(e) => setImageFiles(Array.from(e.target.files ?? []))}
/>
<Button type="submit" loading={submitting}>
Update
</Button>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<Card className="rounded-2xl">
<CardHeader>
<CardTitle className="text-base">List</CardTitle>
</CardHeader>
<CardContent className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Guests</TableHead>
<TableHead>Rate</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rooms.map((r) => (
<TableRow key={r.id}>
<TableCell>
{r.imageKeys?.length > 0 ? (
<img
src={r.imageKeys[0]}
alt={r.name}
width={48}
height={48}
className="object-cover rounded"
/>
) : (
<div className="w-12 h-12 flex items-center justify-center text-xs text-gray-400 border rounded">
No Image
</div>
)}
</TableCell>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.roomTypeSlug}
</TableCell>
<TableCell>{r.maxGuests}</TableCell>
<TableCell>{formatMoney(r.baseRate)}</TableCell>
<TableCell>
<Badge variant="secondary">{r.status}</Badge>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => startEdit(r)}>
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardContent className="space-y-2 md:hidden">
{rooms.map((r) => (
<div key={r.id} className="rounded-xl border p-3 text-sm">
<p className="font-medium">{r.name}</p>
<p className="text-muted-foreground">
{r.roomTypeSlug} · {formatMoney(r.baseRate)}
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<Badge>{r.status}</Badge>
<Button size="sm" variant="ghost" onClick={() => startEdit(r)}>
Edit
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}