file uploader and image updates

This commit is contained in:
brooktewabe 2026-04-15 14:54:04 +03:00
parent 2375f481ba
commit 7bdd8c77a2
5 changed files with 655 additions and 129 deletions

View File

@ -0,0 +1,83 @@
import { InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type FileUploadFieldProps = {
id: string;
label: string;
helperText: string;
files: File[];
multiple?: boolean;
required?: boolean;
accept?: string;
buttonLabel?: string;
emptyLabel?: string;
className?: string;
onChange: InputHTMLAttributes<HTMLInputElement>["onChange"];
};
export function FileUploadField({
id,
label,
helperText,
files,
multiple = false,
required = false,
accept = "image/*",
buttonLabel = "Choose files",
emptyLabel = "No files selected yet",
className,
onChange,
}: FileUploadFieldProps) {
const hasFiles = files.length > 0;
return (
<div className={cn("space-y-2", className)}>
<label htmlFor={id} className="text-sm font-medium leading-none">
{label}
</label>
<label
htmlFor={id}
className={cn(
"flex cursor-pointer flex-col gap-3 rounded-xl border border-dashed px-4 py-4 transition-colors",
hasFiles
? "border-primary/50 bg-primary/5"
: "border-input bg-muted/30 hover:border-primary/40 hover:bg-muted/50"
)}
>
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium">
{hasFiles ? `${files.length} file${files.length === 1 ? "" : "s"} selected` : emptyLabel}
</p>
<p className="text-xs text-muted-foreground">{helperText}</p>
</div>
<span className="rounded-lg border bg-background px-3 py-2 text-xs font-medium">
{buttonLabel}
</span>
</div>
{hasFiles && (
<div className="flex flex-wrap gap-2">
{files.map((file) => (
<span
key={`${file.name}-${file.size}-${file.lastModified}`}
className="rounded-full bg-background px-3 py-1 text-xs text-muted-foreground"
>
{file.name}
</span>
))}
</div>
)}
</label>
<input
id={id}
type="file"
className="sr-only"
multiple={multiple}
required={required}
accept={accept}
onChange={onChange}
/>
</div>
);
}

View File

@ -52,7 +52,8 @@ async function request(
const headers: Record<string, string> = { const headers: Record<string, string> = {
...(init?.headers as Record<string, string>), ...(init?.headers as Record<string, string>),
}; };
if (body !== undefined) headers['Content-Type'] = 'application/json'; const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
if (body !== undefined && !isFormData) headers["Content-Type"] = "application/json";
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
const url = `${getBaseUrl()}${API_PREFIX}${resolved}`; const url = `${getBaseUrl()}${API_PREFIX}${resolved}`;
@ -60,7 +61,12 @@ async function request(
...init, ...init,
method, method,
headers, headers,
body: body !== undefined ? JSON.stringify(body) : init?.body, body:
body === undefined
? init?.body
: isFormData
? (body as FormData)
: JSON.stringify(body),
}); });
} }

View File

@ -110,6 +110,7 @@ export interface RoomTypeCatalog {
export interface Room { export interface Room {
id: string; id: string;
imageKeys: string[];
name: string; name: string;
roomTypeSlug: string; roomTypeSlug: string;
floor?: string; floor?: string;

View File

@ -9,6 +9,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { FileUploadField } from "@/components/ui/file-upload-field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -54,6 +55,7 @@ const SPA_STATUS = ["PENDING", "CONFIRMED", "COMPLETED", "CANCELLED"] as const;
type MenuItem = { type MenuItem = {
id: string; id: string;
image?: string;
name: string; name: string;
category: string; category: string;
unitPrice: string; unitPrice: string;
@ -83,6 +85,7 @@ type LaundryRow = {
type Offering = { type Offering = {
id: string; id: string;
image?: string;
kind: string; kind: string;
name: string; name: string;
price: string; price: string;
@ -112,14 +115,20 @@ export function GuestServicesPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [mOpen, setMOpen] = useState(false); const [mOpen, setMOpen] = useState(false);
const [mEditOpen, setMEditOpen] = useState(false);
const [editingMenuId, setEditingMenuId] = useState<string | null>(null);
const [mName, setMName] = useState(""); const [mName, setMName] = useState("");
const [mCat, setMCat] = useState<(typeof MENU_CATS)[number]>("FOOD"); const [mCat, setMCat] = useState<(typeof MENU_CATS)[number]>("FOOD");
const [mPrice, setMPrice] = useState("0"); const [mPrice, setMPrice] = useState("0");
const [mImageFile, setMImageFile] = useState<File | null>(null);
const [oOpen, setOOpen] = useState(false); const [oOpen, setOOpen] = useState(false);
const [oEditOpen, setOEditOpen] = useState(false);
const [editingOfferingId, setEditingOfferingId] = useState<string | null>(null);
const [oKind, setOKind] = useState<(typeof OFFER_KINDS)[number]>("SPA_SESSION"); const [oKind, setOKind] = useState<(typeof OFFER_KINDS)[number]>("SPA_SESSION");
const [oName, setOName] = useState(""); const [oName, setOName] = useState("");
const [oPrice, setOPrice] = useState("0"); const [oPrice, setOPrice] = useState("0");
const [oImageFile, setOImageFile] = useState<File | null>(null);
const loadMenu = useCallback(async () => { const loadMenu = useCallback(async () => {
const r = await apiGet<{ data: MenuItem[] }>("/menu/items"); const r = await apiGet<{ data: MenuItem[] }>("/menu/items");
@ -158,30 +167,98 @@ export function GuestServicesPage() {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
async function addMenu(e: React.FormEvent) { function resetMenuForm() {
e.preventDefault(); setEditingMenuId(null);
await apiPost("/menu/items", {
name: mName,
category: mCat,
unitPrice: mPrice,
isAvailable: true,
});
setMOpen(false);
setMName(""); setMName("");
setMPrice("0"); setMPrice("0");
setMCat("FOOD");
setMImageFile(null);
}
async function addMenu(e: React.FormEvent) {
e.preventDefault();
const formData = new FormData();
formData.append("name", mName);
formData.append("category", mCat);
formData.append("unitPrice", mPrice);
formData.append("isAvailable", "true");
if (mImageFile) formData.append("image", mImageFile);
await apiPost("/menu/items", formData);
setMOpen(false);
resetMenuForm();
void loadMenu(); void loadMenu();
} }
function startMenuEdit(item: MenuItem) {
setEditingMenuId(item.id);
setMName(item.name);
setMCat(item.category as (typeof MENU_CATS)[number]);
setMPrice(item.unitPrice);
setMImageFile(null);
setMEditOpen(true);
}
async function updateMenu(e: React.FormEvent) {
e.preventDefault();
if (!editingMenuId) return;
const formData = new FormData();
formData.append("name", mName);
formData.append("category", mCat);
formData.append("unitPrice", mPrice);
if (mImageFile) formData.append("image", mImageFile);
await apiPatch(`/menu/items/${editingMenuId}`, formData);
setMEditOpen(false);
resetMenuForm();
void loadMenu();
}
function resetOfferingForm() {
setEditingOfferingId(null);
setOKind("SPA_SESSION");
setOName("");
setOPrice("0");
setOImageFile(null);
}
async function addOffering(e: React.FormEvent) { async function addOffering(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
await apiPost("/spa/offerings", { const formData = new FormData();
kind: oKind, formData.append("kind", oKind);
name: oName, formData.append("name", oName);
price: oPrice, formData.append("price", oPrice);
}); if (oImageFile) formData.append("image", oImageFile);
await apiPost("/spa/offerings", formData);
setOOpen(false); setOOpen(false);
setOName(""); resetOfferingForm();
setOPrice("0"); void loadSpa();
}
function startOfferingEdit(offering: Offering) {
setEditingOfferingId(offering.id);
setOKind(offering.kind as (typeof OFFER_KINDS)[number]);
setOName(offering.name);
setOPrice(offering.price);
setOImageFile(null);
setOEditOpen(true);
}
async function updateOffering(e: React.FormEvent) {
e.preventDefault();
if (!editingOfferingId) return;
const formData = new FormData();
formData.append("kind", oKind);
formData.append("name", oName);
formData.append("price", oPrice);
if (oImageFile) formData.append("image", oImageFile);
await apiPatch(`/spa/offerings/${editingOfferingId}`, formData);
setOEditOpen(false);
resetOfferingForm();
void loadSpa(); void loadSpa();
} }
@ -217,7 +294,14 @@ export function GuestServicesPage() {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Menu items</CardTitle> <CardTitle className="text-base">Menu items</CardTitle>
{canOps && ( {canOps && (
<Dialog open={mOpen} onOpenChange={setMOpen}> <>
<Dialog
open={mOpen}
onOpenChange={(next) => {
setMOpen(next);
if (!next) resetMenuForm();
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm">Add item</Button> <Button size="sm">Add item</Button>
</DialogTrigger> </DialogTrigger>
@ -252,24 +336,95 @@ export function GuestServicesPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Unit price (ETB)</Label> <Label>Unit price (ETB)</Label>
<Input <Input
required required
value={mPrice} value={mPrice}
onChange={(e) => setMPrice(e.target.value)} onChange={(e) => setMPrice(e.target.value)}
/>
</div>
<FileUploadField
id="menu-item-image"
label="Image"
required
files={mImageFile ? [mImageFile] : []}
buttonLabel="Upload image"
emptyLabel="Select a menu item image"
helperText="Add the image guests will see for this menu item."
onChange={(e) => setMImageFile(e.target.files?.[0] ?? null)}
/> />
</div> <Button type="submit">Save</Button>
<Button type="submit">Save</Button> </form>
</form> </DialogContent>
</DialogContent> </Dialog>
</Dialog> <Dialog
open={mEditOpen}
onOpenChange={(next) => {
setMEditOpen(next);
if (!next) resetMenuForm();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit menu item</DialogTitle>
</DialogHeader>
<form onSubmit={updateMenu} 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>
<FileUploadField
id="menu-item-image-edit"
label="Replace image"
files={mImageFile ? [mImageFile] : []}
buttonLabel="Choose replacement"
emptyLabel="Keep current menu item image"
helperText="Leave this empty to keep the current image."
onChange={(e) => setMImageFile(e.target.files?.[0] ?? null)}
/>
<Button type="submit">Update</Button>
</form>
</DialogContent>
</Dialog>
</>
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead>Price</TableHead> <TableHead>Price</TableHead>
@ -280,23 +435,33 @@ export function GuestServicesPage() {
<TableBody> <TableBody>
{menu.map((it) => ( {menu.map((it) => (
<TableRow key={it.id}> <TableRow key={it.id}>
<TableCell>
<img src={it.image} alt={it.name} width={48} height={48} />
</TableCell>
<TableCell>{it.name}</TableCell> <TableCell>{it.name}</TableCell>
<TableCell className="text-xs">{it.category}</TableCell> <TableCell className="text-xs">{it.category}</TableCell>
<TableCell>{formatMoney(Number(it.unitPrice), "ETB")}</TableCell> <TableCell>{formatMoney(Number(it.unitPrice), "ETB")}</TableCell>
<TableCell>{it.isAvailable ? "Yes" : "No"}</TableCell> <TableCell>{it.isAvailable ? "Yes" : "No"}</TableCell>
{canOps && ( {canOps && (
<TableCell> <TableCell>
<Button <div className="flex justify-end gap-2">
size="sm" <Button size="sm" variant="ghost" onClick={() => startMenuEdit(it)}>
variant="ghost" Edit
onClick={() => </Button>
void apiPatch(`/menu/items/${it.id}`, { <Button
isAvailable: !it.isAvailable, size="sm"
}).then(() => loadMenu()) variant="ghost"
} onClick={() => {
> const formData = new FormData();
Toggle formData.append("isAvailable", String(!it.isAvailable));
</Button> void apiPatch(`/menu/items/${it.id}`, formData).then(() =>
loadMenu()
);
}}
>
Change availability
</Button>
</div>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@ -344,7 +509,7 @@ export function GuestServicesPage() {
}).then(() => loadRs()) }).then(() => loadRs())
} }
> >
<SelectTrigger className="h-8 w-[160px]"> <SelectTrigger className="h-8 w-40">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -399,7 +564,7 @@ export function GuestServicesPage() {
}).then(() => loadLaundry()) }).then(() => loadLaundry())
} }
> >
<SelectTrigger className="h-8 w-[160px]"> <SelectTrigger className="h-8 w-40">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -427,7 +592,14 @@ export function GuestServicesPage() {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Offerings</CardTitle> <CardTitle className="text-base">Offerings</CardTitle>
{canOps && ( {canOps && (
<Dialog open={oOpen} onOpenChange={setOOpen}> <>
<Dialog
open={oOpen}
onOpenChange={(next) => {
setOOpen(next);
if (!next) resetOfferingForm();
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm">Add offering</Button> <Button size="sm">Add offering</Button>
</DialogTrigger> </DialogTrigger>
@ -462,37 +634,133 @@ export function GuestServicesPage() {
onChange={(e) => setOName(e.target.value)} onChange={(e) => setOName(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Price (ETB)</Label> <Label>Price (ETB)</Label>
<Input <Input
required required
value={oPrice} value={oPrice}
onChange={(e) => setOPrice(e.target.value)} onChange={(e) => setOPrice(e.target.value)}
/>
</div>
<FileUploadField
id="offering-image"
label="Image"
required
files={oImageFile ? [oImageFile] : []}
buttonLabel="Upload image"
emptyLabel="Select a spa or gym image"
helperText="Add the image that represents this spa or gym offering."
onChange={(e) => setOImageFile(e.target.files?.[0] ?? null)}
/> />
</div> <Button type="submit">Save</Button>
<Button type="submit">Save</Button> </form>
</form> </DialogContent>
</DialogContent> </Dialog>
</Dialog> <Dialog
open={oEditOpen}
onOpenChange={(next) => {
setOEditOpen(next);
if (!next) resetOfferingForm();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit offering</DialogTitle>
</DialogHeader>
<form onSubmit={updateOffering} 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>
<FileUploadField
id="offering-image-edit"
label="Replace image"
files={oImageFile ? [oImageFile] : []}
buttonLabel="Choose replacement"
emptyLabel="Keep current offering image"
helperText="Leave this empty to keep the current image."
onChange={(e) => setOImageFile(e.target.files?.[0] ?? null)}
/>
<Button type="submit">Update</Button>
</form>
</DialogContent>
</Dialog>
</>
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead></TableHead>
<TableHead>Kind</TableHead> <TableHead>Name</TableHead>
<TableHead>Price</TableHead> <TableHead>Kind</TableHead>
<TableHead>Active</TableHead> <TableHead>Price</TableHead>
</TableRow> <TableHead>Active</TableHead>
{canOps && <TableHead />}
</TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{offerings.map((x) => ( {offerings.map((x) => (
<TableRow key={x.id}> <TableRow key={x.id}>
<TableCell>{x.name}</TableCell> <TableCell>
<TableCell className="text-xs">{x.kind}</TableCell> <img src={x.image} alt={x.name} width={48} height={48} />
<TableCell>{formatMoney(Number(x.price), "ETB")}</TableCell> </TableCell>
<TableCell>{x.isActive ? "Yes" : "No"}</TableCell> <TableCell>{x.name}</TableCell>
<TableCell className="text-xs">{x.kind}</TableCell>
<TableCell>{formatMoney(Number(x.price), "ETB")}</TableCell>
<TableCell>{x.isActive ? "Yes" : "No"}</TableCell>
{canOps && (
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => startOfferingEdit(x)}>
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
const formData = new FormData();
formData.append("isActive", String(!x.isActive));
void apiPatch(`/spa/offerings/${x.id}`, formData).then(() =>
loadSpa()
);
}}
>
Change availability
</Button>
</TableCell>
)}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@ -10,6 +10,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { FileUploadField } from "@/components/ui/file-upload-field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -27,7 +28,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { apiGet, apiPost } from "@/lib/api"; import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
isLikelyApiHotelRoom, isLikelyApiHotelRoom,
@ -44,113 +45,254 @@ export function RoomsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editingRoomId, setEditingRoomId] = useState<string | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug); const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
const [maxGuests, setMaxGuests] = useState("2"); const [maxGuests, setMaxGuests] = useState("2");
const [baseRate, setBaseRate] = useState("120"); const [baseRate, setBaseRate] = useState("120");
const [imageFiles, setImageFiles] = useState<File[]>([]);
function load() { function load() {
setLoading(true); setLoading(true);
apiGet<{ data: unknown[] }>("/rooms").then((r) => { apiGet<{ data: unknown[] }>("/rooms")
const mapped = r.data.map((row) => .then((r) => {
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room) const mapped = r.data.map((row) =>
); isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
setRooms(mapped); );
}).finally(() => setLoading(false)); setRooms(mapped);
})
.finally(() => setLoading(false));
} }
useEffect(() => { useEffect(() => {
load(); load();
}, [selectedPropertyId]); }, [selectedPropertyId]);
function resetForm() {
setEditingRoomId(null);
setName("");
setSlug(ROOM_CATALOGUE[0].slug);
setMaxGuests("2");
setBaseRate("120");
setImageFiles([]);
}
async function addRoom(e: React.FormEvent) { async function addRoom(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setSubmitting(true); setSubmitting(true);
try { try {
await apiPost<Room>("/rooms", { const formData = new FormData();
name, formData.append("name", name);
roomType: slug, formData.append("roomType", slug);
maxGuests: Number(maxGuests), formData.append("maxGuests", maxGuests);
baseRate: Number(baseRate), formData.append("baseRate", baseRate);
}); imageFiles.forEach((file) => formData.append("images", file));
await apiPost<Room>("/rooms", formData);
setOpen(false); setOpen(false);
setName(""); resetForm();
load(); load();
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} }
if (loading && rooms.length === 0) return ( function startEdit(room: Room) {
<div className="flex min-h-[400px] items-center justify-center"> setEditingRoomId(room.id);
<Spinner size={32} /> setName(room.name);
</div> 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Rooms</h1> <h1 className="text-2xl font-bold">Rooms</h1>
<Dialog open={open} onOpenChange={setOpen}> <div className="flex items-center gap-2">
<DialogTrigger asChild> <Dialog
<Button>+ Add room</Button> open={open}
</DialogTrigger> onOpenChange={(next) => {
<DialogContent> setOpen(next);
<DialogHeader> if (!next) resetForm();
<DialogTitle>Add room</DialogTitle> }}
</DialogHeader> >
<form onSubmit={addRoom} className="grid gap-3"> <DialogTrigger asChild>
<div className="space-y-2"> <Button>+ Add room</Button>
<Label>Unit name</Label> </DialogTrigger>
<Input <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 required
placeholder="Room 104" files={imageFiles}
value={name} buttonLabel="Upload images"
onChange={(e) => setName(e.target.value)} 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 ?? []))}
/> />
</div> <Button type="submit" loading={submitting}>
<div className="space-y-2"> Save
<Label>Type</Label> </Button>
<Select value={slug} onValueChange={setSlug}> </form>
<SelectTrigger> </DialogContent>
<SelectValue /> </Dialog>
</SelectTrigger>
<SelectContent> <Dialog
{ROOM_CATALOGUE.map((r) => ( open={editOpen}
<SelectItem key={r.slug} value={r.slug}> onOpenChange={(next) => {
{r.name} setEditOpen(next);
</SelectItem> if (!next) resetForm();
))} }}
</SelectContent> >
</Select> <DialogContent>
</div> <DialogHeader>
<div className="grid grid-cols-2 gap-3"> <DialogTitle>Edit room</DialogTitle>
</DialogHeader>
<form onSubmit={updateRoom} className="grid gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Max guests</Label> <Label>Unit name</Label>
<Input <Input
type="number" required
value={maxGuests} placeholder="Room 104"
onChange={(e) => setMaxGuests(e.target.value)} value={name}
onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Base rate</Label> <Label>Type</Label>
<Input <Select value={slug} onValueChange={setSlug}>
type="number" <SelectTrigger>
value={baseRate} <SelectValue />
onChange={(e) => setBaseRate(e.target.value)} </SelectTrigger>
/> <SelectContent>
{ROOM_CATALOGUE.map((r) => (
<SelectItem key={r.slug} value={r.slug}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> <div className="grid grid-cols-2 gap-3">
<Button type="submit" loading={submitting}>Save</Button> <div className="space-y-2">
</form> <Label>Max guests</Label>
</DialogContent> <Input
</Dialog> 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> </div>
<Card className="rounded-2xl"> <Card className="rounded-2xl">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Inventory</CardTitle> <CardTitle className="text-base">List</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="hidden md:block"> <CardContent className="hidden md:block">
<Table> <Table>
@ -161,11 +303,27 @@ export function RoomsPage() {
<TableHead>Guests</TableHead> <TableHead>Guests</TableHead>
<TableHead>Rate</TableHead> <TableHead>Rate</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rooms.map((r) => ( {rooms.map((r) => (
<TableRow key={r.id}> <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="font-medium">{r.name}</TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{r.roomTypeSlug} {r.roomTypeSlug}
@ -175,6 +333,11 @@ export function RoomsPage() {
<TableCell> <TableCell>
<Badge variant="secondary">{r.status}</Badge> <Badge variant="secondary">{r.status}</Badge>
</TableCell> </TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => startEdit(r)}>
Edit
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -187,7 +350,12 @@ export function RoomsPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{r.roomTypeSlug} · {formatMoney(r.baseRate)} {r.roomTypeSlug} · {formatMoney(r.baseRate)}
</p> </p>
<Badge className="mt-2">{r.status}</Badge> <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> </div>
))} ))}
</CardContent> </CardContent>