diff --git a/src/components/ui/file-upload-field.tsx b/src/components/ui/file-upload-field.tsx new file mode 100644 index 0000000..7b085ed --- /dev/null +++ b/src/components/ui/file-upload-field.tsx @@ -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["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 ( +
+ + + +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index d30f27c..c6931db 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,5 @@ import { parseApiError } from '@/lib/auth-api'; -const API_PREFIX = '/api'; function getBaseUrl(): string { return import.meta.env.VITE_API_BASE_URL ?? ''; @@ -52,15 +51,21 @@ async function request( const headers: Record = { ...(init?.headers as Record), }; - 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}`; - const url = `${getBaseUrl()}${API_PREFIX}${resolved}`; + const url = `${getBaseUrl()}${resolved}`; return fetch(url, { ...init, method, headers, - body: body !== undefined ? JSON.stringify(body) : init?.body, + body: + body === undefined + ? init?.body + : isFormData + ? (body as FormData) + : JSON.stringify(body), }); } @@ -97,7 +102,7 @@ export async function apiDownloadBlob( const headers: Record = { ...(init?.headers as Record) }; if (token) headers.Authorization = `Bearer ${token}`; - const url = `${getBaseUrl()}${API_PREFIX}${resolved}`; + const url = `${getBaseUrl()}${resolved}`; const res = await fetch(url, { ...init, method: 'GET', headers }); if (!res.ok) throw new Error(await parseApiError(res)); diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts index ca9cab7..c3b5272 100644 --- a/src/lib/auth-api.ts +++ b/src/lib/auth-api.ts @@ -1,7 +1,5 @@ import type { RegisterHotelStaffDto, StaffAccess } from "@/lib/types"; -const API_ROOT = "/api"; - let getPropertyId: () => string | null = () => null; /** Register property scope for hotel auth-API paths (e.g. staff management). */ @@ -31,7 +29,7 @@ function rewriteHotelPath(path: string): string { function apiUrl(path: string): string { const base = import.meta.env.VITE_API_BASE_URL ?? ""; const resolved = rewriteHotelPath(path); - return `${base}${API_ROOT}${resolved}`; + return `${base}${resolved}`; } export async function parseApiError(res: Response): Promise { diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 5df087b..5f62316 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -110,6 +110,7 @@ export interface RoomTypeCatalog { export interface Room { id: string; + imageKeys: string[]; name: string; roomTypeSlug: string; floor?: string; diff --git a/src/pages/GuestServicesPage.tsx b/src/pages/GuestServicesPage.tsx index 5d25eba..7e6e2fd 100644 --- a/src/pages/GuestServicesPage.tsx +++ b/src/pages/GuestServicesPage.tsx @@ -9,6 +9,7 @@ import { 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 { @@ -54,6 +55,7 @@ const SPA_STATUS = ["PENDING", "CONFIRMED", "COMPLETED", "CANCELLED"] as const; type MenuItem = { id: string; + image?: string; name: string; category: string; unitPrice: string; @@ -83,6 +85,7 @@ type LaundryRow = { type Offering = { id: string; + image?: string; kind: string; name: string; price: string; @@ -112,14 +115,20 @@ export function GuestServicesPage() { const [loading, setLoading] = useState(true); const [mOpen, setMOpen] = useState(false); + const [mEditOpen, setMEditOpen] = useState(false); + const [editingMenuId, setEditingMenuId] = useState(null); const [mName, setMName] = useState(""); const [mCat, setMCat] = useState<(typeof MENU_CATS)[number]>("FOOD"); const [mPrice, setMPrice] = useState("0"); + const [mImageFile, setMImageFile] = useState(null); const [oOpen, setOOpen] = useState(false); + const [oEditOpen, setOEditOpen] = useState(false); + const [editingOfferingId, setEditingOfferingId] = useState(null); const [oKind, setOKind] = useState<(typeof OFFER_KINDS)[number]>("SPA_SESSION"); const [oName, setOName] = useState(""); const [oPrice, setOPrice] = useState("0"); + const [oImageFile, setOImageFile] = useState(null); const loadMenu = useCallback(async () => { const r = await apiGet<{ data: MenuItem[] }>("/menu/items"); @@ -158,30 +167,98 @@ export function GuestServicesPage() { 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); + function resetMenuForm() { + setEditingMenuId(null); setMName(""); 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(); } + 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) { e.preventDefault(); - await apiPost("/spa/offerings", { - kind: oKind, - name: oName, - price: oPrice, - }); + const formData = new FormData(); + formData.append("kind", oKind); + formData.append("name", oName); + formData.append("price", oPrice); + if (oImageFile) formData.append("image", oImageFile); + + await apiPost("/spa/offerings", formData); setOOpen(false); - setOName(""); - setOPrice("0"); + resetOfferingForm(); + 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(); } @@ -217,7 +294,14 @@ export function GuestServicesPage() { Menu items {canOps && ( - + <> + { + setMOpen(next); + if (!next) resetMenuForm(); + }} + > @@ -252,24 +336,95 @@ export function GuestServicesPage() { -
- - + + setMPrice(e.target.value)} + onChange={(e) => setMPrice(e.target.value)} + /> +
+ setMImageFile(e.target.files?.[0] ?? null)} /> - - - - -
+ + + +
+ { + setMEditOpen(next); + if (!next) resetMenuForm(); + }} + > + + + Edit menu item + +
+
+ + setMName(e.target.value)} + /> +
+
+ + +
+
+ + setMPrice(e.target.value)} + /> +
+ setMImageFile(e.target.files?.[0] ?? null)} + /> + + +
+
+ )}
+ Name Category Price @@ -280,23 +435,33 @@ export function GuestServicesPage() { {menu.map((it) => ( + + {it.name} + {it.name} {it.category} {formatMoney(Number(it.unitPrice), "ETB")} {it.isAvailable ? "Yes" : "No"} {canOps && ( - +
+ + +
)}
@@ -344,7 +509,7 @@ export function GuestServicesPage() { }).then(() => loadRs()) } > - + @@ -399,7 +564,7 @@ export function GuestServicesPage() { }).then(() => loadLaundry()) } > - + @@ -427,7 +592,14 @@ export function GuestServicesPage() { Offerings {canOps && ( - + <> + { + setOOpen(next); + if (!next) resetOfferingForm(); + }} + > @@ -462,37 +634,133 @@ export function GuestServicesPage() { onChange={(e) => setOName(e.target.value)} /> -
- - + + setOPrice(e.target.value)} + onChange={(e) => setOPrice(e.target.value)} + /> +
+ setOImageFile(e.target.files?.[0] ?? null)} /> - - - - -
+ + + +
+ { + setOEditOpen(next); + if (!next) resetOfferingForm(); + }} + > + + + Edit offering + +
+
+ + +
+
+ + setOName(e.target.value)} + /> +
+
+ + setOPrice(e.target.value)} + /> +
+ setOImageFile(e.target.files?.[0] ?? null)} + /> + + +
+
+ )}
- - Name - Kind - Price - Active - + + + Name + Kind + Price + Active + {canOps && } + {offerings.map((x) => ( - {x.name} - {x.kind} - {formatMoney(Number(x.price), "ETB")} - {x.isActive ? "Yes" : "No"} + + {x.name} + + {x.name} + {x.kind} + {formatMoney(Number(x.price), "ETB")} + {x.isActive ? "Yes" : "No"} + {canOps && ( + + + + + + )} ))} diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx index 55f13b1..040f4cd 100644 --- a/src/pages/RoomsPage.tsx +++ b/src/pages/RoomsPage.tsx @@ -10,6 +10,7 @@ import { 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 { @@ -27,7 +28,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { apiGet, apiPost } from "@/lib/api"; +import { apiGet, apiPatch, apiPost } from "@/lib/api"; import { Spinner } from "@/components/ui/spinner"; import { isLikelyApiHotelRoom, @@ -44,113 +45,254 @@ export function RoomsPage() { const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [open, setOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [editingRoomId, setEditingRoomId] = useState(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([]); 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)); + 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 { - await apiPost("/rooms", { - name, - roomType: slug, - maxGuests: Number(maxGuests), - baseRate: Number(baseRate), - }); + 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("/rooms", formData); setOpen(false); - setName(""); + resetForm(); load(); } finally { setSubmitting(false); } } - if (loading && rooms.length === 0) return ( -
- -
- ); + 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(`/rooms/${editingRoomId}`, formData); + setEditOpen(false); + resetForm(); + load(); + } finally { + setSubmitting(false); + } + } + + if (loading && rooms.length === 0) + return ( +
+ +
+ ); return (

Rooms

- - - - - - - Add room - -
-
- - + { + setOpen(next); + if (!next) resetForm(); + }} + > + + + + + + Add room + + +
+ + setName(e.target.value)} + /> +
+
+ + +
+
+
+ + setMaxGuests(e.target.value)} + /> +
+
+ + setBaseRate(e.target.value)} + /> +
+
+ setName(e.target.value)} + 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 ?? []))} /> -
-
- - -
-
+ + + +
+ + { + setEditOpen(next); + if (!next) resetForm(); + }} + > + + + Edit room + +
- + setMaxGuests(e.target.value)} + required + placeholder="Room 104" + value={name} + onChange={(e) => setName(e.target.value)} />
- - setBaseRate(e.target.value)} - /> + +
-
- - - - +
+
+ + setMaxGuests(e.target.value)} + /> +
+
+ + setBaseRate(e.target.value)} + /> +
+
+ setImageFiles(Array.from(e.target.files ?? []))} + /> + + + + +
- Inventory + List
@@ -161,11 +303,27 @@ export function RoomsPage() { GuestsRateStatus + {rooms.map((r) => ( + + {r.imageKeys?.length > 0 ? ( + {r.name} + ) : ( +
+ No Image +
+ )} +
{r.name} {r.roomTypeSlug} @@ -175,6 +333,11 @@ export function RoomsPage() { {r.status} + + +
))}
@@ -187,7 +350,12 @@ export function RoomsPage() {

{r.roomTypeSlug} ยท {formatMoney(r.baseRate)}

- {r.status} +
+ {r.status} + +
))}