Compare commits
No commits in common. "7bdd8c77a26bcd276032d090aaa5e8c0471fdb3f" and "cb5ad1965580499d3bc5ae691c8a078b62f42864" have entirely different histories.
7bdd8c77a2
...
cb5ad19655
110
src/App.tsx
110
src/App.tsx
|
|
@ -1,87 +1,67 @@
|
||||||
import type { ComponentType } from "react";
|
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { Suspense, lazy } from "react";
|
|
||||||
|
|
||||||
import { AppLayout } from "@/components/layout/AppLayout";
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { Spinner } from "./components/ui/spinner";
|
import { BookingDetailPage } from "@/pages/BookingDetailPage";
|
||||||
|
import { BookingsPage } from "@/pages/BookingsPage";
|
||||||
function lazyNamed<T extends ComponentType<unknown>, TModule extends Record<string, T>>(
|
import { CalendarPage } from "@/pages/CalendarPage";
|
||||||
importer: () => Promise<TModule>,
|
import { CustomersPage } from "@/pages/CustomersPage";
|
||||||
exportName: keyof TModule,
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
) {
|
import { DiscountCodesPage } from "@/pages/DiscountCodesPage";
|
||||||
return lazy(async () => ({
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
default: (await importer())[exportName],
|
import { NewBookingPage } from "@/pages/NewBookingPage";
|
||||||
}));
|
import { PaymentsPage } from "@/pages/PaymentsPage";
|
||||||
}
|
import { ReferralCodesPage } from "@/pages/ReferralCodesPage";
|
||||||
|
import { ReservationsPage } from "@/pages/ReservationsPage";
|
||||||
// Lazy load pages
|
import { RoomsPage } from "@/pages/RoomsPage";
|
||||||
const LoginPage = lazyNamed(() => import("@/pages/LoginPage"), "LoginPage");
|
import { SettingsPage } from "@/pages/SettingsPage";
|
||||||
const DashboardPage = lazyNamed(() => import("@/pages/DashboardPage"), "DashboardPage");
|
import { TransactionsPage } from "@/pages/TransactionsPage";
|
||||||
const ReservationsPage = lazyNamed(() => import("@/pages/ReservationsPage"), "ReservationsPage");
|
import { VisitsPage } from "@/pages/VisitsPage";
|
||||||
const BookingsPage = lazyNamed(() => import("@/pages/BookingsPage"), "BookingsPage");
|
import { ManageUsersPage } from "@/pages/ManageUsersPage";
|
||||||
const NewBookingPage = lazyNamed(() => import("@/pages/NewBookingPage"), "NewBookingPage");
|
import { GuestServicesPage } from "@/pages/GuestServicesPage";
|
||||||
const BookingDetailPage = lazyNamed(() => import("@/pages/BookingDetailPage"), "BookingDetailPage");
|
import { LoyaltyPointsPage } from "@/pages/LoyaltyPointsPage";
|
||||||
const CalendarPage = lazyNamed(() => import("@/pages/CalendarPage"), "CalendarPage");
|
import { HotelRafflesPage } from "@/pages/HotelRafflesPage";
|
||||||
const RoomsPage = lazyNamed(() => import("@/pages/RoomsPage"), "RoomsPage");
|
|
||||||
const GuestServicesPage = lazyNamed(() => import("@/pages/GuestServicesPage"), "GuestServicesPage");
|
|
||||||
const LoyaltyPointsPage = lazyNamed(() => import("@/pages/LoyaltyPointsPage"), "LoyaltyPointsPage");
|
|
||||||
const HotelRafflesPage = lazyNamed(() => import("@/pages/HotelRafflesPage"), "HotelRafflesPage");
|
|
||||||
const CustomersPage = lazyNamed(() => import("@/pages/CustomersPage"), "CustomersPage");
|
|
||||||
const TransactionsPage = lazyNamed(() => import("@/pages/TransactionsPage"), "TransactionsPage");
|
|
||||||
const PaymentsPage = lazyNamed(() => import("@/pages/PaymentsPage"), "PaymentsPage");
|
|
||||||
const VisitsPage = lazyNamed(() => import("@/pages/VisitsPage"), "VisitsPage");
|
|
||||||
const DiscountCodesPage = lazyNamed(() => import("@/pages/DiscountCodesPage"), "DiscountCodesPage");
|
|
||||||
const ReferralCodesPage = lazyNamed(() => import("@/pages/ReferralCodesPage"), "ReferralCodesPage");
|
|
||||||
const ManageUsersPage = lazyNamed(() => import("@/pages/ManageUsersPage"), "ManageUsersPage");
|
|
||||||
const SettingsPage = lazyNamed(() => import("@/pages/SettingsPage"), "SettingsPage");
|
|
||||||
|
|
||||||
function ProtectedLayout() {
|
function ProtectedLayout() {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const bootstrapped = useAuthStore((s) => s.bootstrapped);
|
const bootstrapped = useAuthStore((s) => s.bootstrapped);
|
||||||
|
|
||||||
if (!bootstrapped) {
|
if (!bootstrapped) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
|
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
|
||||||
<Spinner />
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken) return <Navigate to="/login" replace />;
|
if (!accessToken) return <Navigate to="/login" replace />;
|
||||||
return <AppLayout />;
|
return <AppLayout />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route element={<ProtectedLayout />}>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route element={<ProtectedLayout />}>
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/reservations" element={<ReservationsPage />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/reservations" element={<ReservationsPage />} />
|
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/guest-services" element={<GuestServicesPage />} />
|
||||||
<Route path="/rooms" element={<RoomsPage />} />
|
<Route path="/loyalty/points" element={<LoyaltyPointsPage />} />
|
||||||
<Route path="/guest-services" element={<GuestServicesPage />} />
|
<Route path="/loyalty/raffles" element={<HotelRafflesPage />} />
|
||||||
<Route path="/loyalty/points" element={<LoyaltyPointsPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/loyalty/raffles" element={<HotelRafflesPage />} />
|
<Route path="/transactions" element={<TransactionsPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/payments" element={<PaymentsPage />} />
|
||||||
<Route path="/transactions" element={<TransactionsPage />} />
|
<Route path="/marketing/visits" element={<VisitsPage />} />
|
||||||
<Route path="/payments" element={<PaymentsPage />} />
|
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
||||||
<Route path="/marketing/visits" element={<VisitsPage />} />
|
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
||||||
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
<Route path="/settings/users" element={<ManageUsersPage />} />
|
||||||
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/users" element={<ManageUsersPage />} />
|
</Route>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Routes>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -52,8 +52,7 @@ 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>),
|
||||||
};
|
};
|
||||||
const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
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}`;
|
||||||
|
|
@ -61,12 +60,7 @@ async function request(
|
||||||
...init,
|
...init,
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body:
|
body: body !== undefined ? JSON.stringify(body) : init?.body,
|
||||||
body === undefined
|
|
||||||
? init?.body
|
|
||||||
: isFormData
|
|
||||||
? (body as FormData)
|
|
||||||
: JSON.stringify(body),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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 {
|
||||||
|
|
@ -55,7 +54,6 @@ 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;
|
||||||
|
|
@ -85,7 +83,6 @@ type LaundryRow = {
|
||||||
|
|
||||||
type Offering = {
|
type Offering = {
|
||||||
id: string;
|
id: string;
|
||||||
image?: string;
|
|
||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: string;
|
price: string;
|
||||||
|
|
@ -115,20 +112,14 @@ 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");
|
||||||
|
|
@ -167,98 +158,30 @@ export function GuestServicesPage() {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
function resetMenuForm() {
|
|
||||||
setEditingMenuId(null);
|
|
||||||
setMName("");
|
|
||||||
setMPrice("0");
|
|
||||||
setMCat("FOOD");
|
|
||||||
setMImageFile(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addMenu(e: React.FormEvent) {
|
async function addMenu(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData();
|
await apiPost("/menu/items", {
|
||||||
formData.append("name", mName);
|
name: mName,
|
||||||
formData.append("category", mCat);
|
category: mCat,
|
||||||
formData.append("unitPrice", mPrice);
|
unitPrice: mPrice,
|
||||||
formData.append("isAvailable", "true");
|
isAvailable: true,
|
||||||
if (mImageFile) formData.append("image", mImageFile);
|
});
|
||||||
|
|
||||||
await apiPost("/menu/items", formData);
|
|
||||||
setMOpen(false);
|
setMOpen(false);
|
||||||
resetMenuForm();
|
setMName("");
|
||||||
|
setMPrice("0");
|
||||||
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();
|
||||||
const formData = new FormData();
|
await apiPost("/spa/offerings", {
|
||||||
formData.append("kind", oKind);
|
kind: oKind,
|
||||||
formData.append("name", oName);
|
name: oName,
|
||||||
formData.append("price", oPrice);
|
price: oPrice,
|
||||||
if (oImageFile) formData.append("image", oImageFile);
|
});
|
||||||
|
|
||||||
await apiPost("/spa/offerings", formData);
|
|
||||||
setOOpen(false);
|
setOOpen(false);
|
||||||
resetOfferingForm();
|
setOName("");
|
||||||
void loadSpa();
|
setOPrice("0");
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,14 +217,7 @@ 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>
|
||||||
|
|
@ -336,95 +252,24 @@ 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)}
|
|
||||||
/>
|
/>
|
||||||
<Button type="submit">Save</Button>
|
</div>
|
||||||
</form>
|
<Button type="submit">Save</Button>
|
||||||
</DialogContent>
|
</form>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
<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>
|
||||||
|
|
@ -435,33 +280,23 @@ 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>
|
||||||
<div className="flex justify-end gap-2">
|
<Button
|
||||||
<Button size="sm" variant="ghost" onClick={() => startMenuEdit(it)}>
|
size="sm"
|
||||||
Edit
|
variant="ghost"
|
||||||
</Button>
|
onClick={() =>
|
||||||
<Button
|
void apiPatch(`/menu/items/${it.id}`, {
|
||||||
size="sm"
|
isAvailable: !it.isAvailable,
|
||||||
variant="ghost"
|
}).then(() => loadMenu())
|
||||||
onClick={() => {
|
}
|
||||||
const formData = new FormData();
|
>
|
||||||
formData.append("isAvailable", String(!it.isAvailable));
|
Toggle
|
||||||
void apiPatch(`/menu/items/${it.id}`, formData).then(() =>
|
</Button>
|
||||||
loadMenu()
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Change availability
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -509,7 +344,7 @@ export function GuestServicesPage() {
|
||||||
}).then(() => loadRs())
|
}).then(() => loadRs())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-40">
|
<SelectTrigger className="h-8 w-[160px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -564,7 +399,7 @@ export function GuestServicesPage() {
|
||||||
}).then(() => loadLaundry())
|
}).then(() => loadLaundry())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-40">
|
<SelectTrigger className="h-8 w-[160px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -592,14 +427,7 @@ 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>
|
||||||
|
|
@ -634,133 +462,37 @@ 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)}
|
|
||||||
/>
|
/>
|
||||||
<Button type="submit">Save</Button>
|
</div>
|
||||||
</form>
|
<Button type="submit">Save</Button>
|
||||||
</DialogContent>
|
</form>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
<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></TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Kind</TableHead>
|
||||||
<TableHead>Kind</TableHead>
|
<TableHead>Price</TableHead>
|
||||||
<TableHead>Price</TableHead>
|
<TableHead>Active</TableHead>
|
||||||
<TableHead>Active</TableHead>
|
</TableRow>
|
||||||
{canOps && <TableHead />}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{offerings.map((x) => (
|
{offerings.map((x) => (
|
||||||
<TableRow key={x.id}>
|
<TableRow key={x.id}>
|
||||||
<TableCell>
|
<TableCell>{x.name}</TableCell>
|
||||||
<img src={x.image} alt={x.name} width={48} height={48} />
|
<TableCell className="text-xs">{x.kind}</TableCell>
|
||||||
</TableCell>
|
<TableCell>{formatMoney(Number(x.price), "ETB")}</TableCell>
|
||||||
<TableCell>{x.name}</TableCell>
|
<TableCell>{x.isActive ? "Yes" : "No"}</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>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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 {
|
||||||
|
|
@ -28,7 +27,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
isLikelyApiHotelRoom,
|
isLikelyApiHotelRoom,
|
||||||
|
|
@ -45,254 +44,113 @@ 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")
|
apiGet<{ data: unknown[] }>("/rooms").then((r) => {
|
||||||
.then((r) => {
|
const mapped = r.data.map((row) =>
|
||||||
const mapped = r.data.map((row) =>
|
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
|
||||||
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
|
);
|
||||||
);
|
setRooms(mapped);
|
||||||
setRooms(mapped);
|
}).finally(() => setLoading(false));
|
||||||
})
|
|
||||||
.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 {
|
||||||
const formData = new FormData();
|
await apiPost<Room>("/rooms", {
|
||||||
formData.append("name", name);
|
name,
|
||||||
formData.append("roomType", slug);
|
roomType: slug,
|
||||||
formData.append("maxGuests", maxGuests);
|
maxGuests: Number(maxGuests),
|
||||||
formData.append("baseRate", baseRate);
|
baseRate: Number(baseRate),
|
||||||
imageFiles.forEach((file) => formData.append("images", file));
|
});
|
||||||
|
|
||||||
await apiPost<Room>("/rooms", formData);
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
resetForm();
|
setName("");
|
||||||
load();
|
load();
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(room: Room) {
|
if (loading && rooms.length === 0) return (
|
||||||
setEditingRoomId(room.id);
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
setName(room.name);
|
<Spinner size={32} />
|
||||||
setSlug(room.roomTypeSlug);
|
</div>
|
||||||
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>
|
||||||
<div className="flex items-center gap-2">
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<Dialog
|
<DialogTrigger asChild>
|
||||||
open={open}
|
<Button>+ Add room</Button>
|
||||||
onOpenChange={(next) => {
|
</DialogTrigger>
|
||||||
setOpen(next);
|
<DialogContent>
|
||||||
if (!next) resetForm();
|
<DialogHeader>
|
||||||
}}
|
<DialogTitle>Add room</DialogTitle>
|
||||||
>
|
</DialogHeader>
|
||||||
<DialogTrigger asChild>
|
<form onSubmit={addRoom} className="grid gap-3">
|
||||||
<Button>+ Add room</Button>
|
<div className="space-y-2">
|
||||||
</DialogTrigger>
|
<Label>Unit name</Label>
|
||||||
<DialogContent>
|
<Input
|
||||||
<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
|
||||||
files={imageFiles}
|
placeholder="Room 104"
|
||||||
buttonLabel="Upload images"
|
value={name}
|
||||||
emptyLabel="Select room images"
|
onChange={(e) => setName(e.target.value)}
|
||||||
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}>
|
</div>
|
||||||
Save
|
<div className="space-y-2">
|
||||||
</Button>
|
<Label>Type</Label>
|
||||||
</form>
|
<Select value={slug} onValueChange={setSlug}>
|
||||||
</DialogContent>
|
<SelectTrigger>
|
||||||
</Dialog>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<Dialog
|
<SelectContent>
|
||||||
open={editOpen}
|
{ROOM_CATALOGUE.map((r) => (
|
||||||
onOpenChange={(next) => {
|
<SelectItem key={r.slug} value={r.slug}>
|
||||||
setEditOpen(next);
|
{r.name}
|
||||||
if (!next) resetForm();
|
</SelectItem>
|
||||||
}}
|
))}
|
||||||
>
|
</SelectContent>
|
||||||
<DialogContent>
|
</Select>
|
||||||
<DialogHeader>
|
</div>
|
||||||
<DialogTitle>Edit room</DialogTitle>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={updateRoom} className="grid gap-3">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Unit name</Label>
|
<Label>Max guests</Label>
|
||||||
<Input
|
<Input
|
||||||
required
|
type="number"
|
||||||
placeholder="Room 104"
|
value={maxGuests}
|
||||||
value={name}
|
onChange={(e) => setMaxGuests(e.target.value)}
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Type</Label>
|
<Label>Base rate</Label>
|
||||||
<Select value={slug} onValueChange={setSlug}>
|
<Input
|
||||||
<SelectTrigger>
|
type="number"
|
||||||
<SelectValue />
|
value={baseRate}
|
||||||
</SelectTrigger>
|
onChange={(e) => setBaseRate(e.target.value)}
|
||||||
<SelectContent>
|
/>
|
||||||
{ROOM_CATALOGUE.map((r) => (
|
|
||||||
<SelectItem key={r.slug} value={r.slug}>
|
|
||||||
{r.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</div>
|
||||||
<div className="space-y-2">
|
<Button type="submit" loading={submitting}>Save</Button>
|
||||||
<Label>Max guests</Label>
|
</form>
|
||||||
<Input
|
</DialogContent>
|
||||||
type="number"
|
</Dialog>
|
||||||
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">List</CardTitle>
|
<CardTitle className="text-base">Inventory</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="hidden md:block">
|
<CardContent className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
|
|
@ -303,27 +161,11 @@ 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}
|
||||||
|
|
@ -333,11 +175,6 @@ 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>
|
||||||
|
|
@ -350,12 +187,7 @@ 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>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<Badge className="mt-2">{r.status}</Badge>
|
||||||
<Badge>{r.status}</Badge>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => startEdit(r)}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,21 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig({
|
||||||
const isProduction = mode === "production";
|
plugins: [react(), tailwindcss()],
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
server: {
|
||||||
|
proxy: {
|
||||||
const backendUrl =
|
"/api": {
|
||||||
env.VITE_PROXY_TARGET?.replace(/\/api\/?$/, "") ||
|
target: process.env.VITE_PROXY_TARGET ?? "http://localhost:3000",
|
||||||
"http://localhost:3000";
|
changeOrigin: true,
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
server: {
|
resolve: {
|
||||||
proxy: {
|
alias: {
|
||||||
"/api": {
|
"@": path.resolve(__dirname, "./src"),
|
||||||
target: backendUrl,
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
build: {
|
});
|
||||||
target: "esnext",
|
|
||||||
minify: "esbuild",
|
|
||||||
sourcemap: !isProduction,
|
|
||||||
reportCompressedSize: false,
|
|
||||||
},
|
|
||||||
esbuild: {
|
|
||||||
drop: isProduction ? ["console", "debugger"] : [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user