366 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|