Compare commits
7 Commits
b8aecf31e3
...
e7f3709c11
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f3709c11 | |||
| 0f9d0b7e6f | |||
| 4ed7161a33 | |||
| 4d229d0b94 | |||
| bd30190f96 | |||
| 893fdc1669 | |||
| 4f11503167 |
94
package-lock.json
generated
94
package-lock.json
generated
|
|
@ -32,7 +32,8 @@
|
||||||
"react-router-dom": "^7.4.0",
|
"react-router-dom": "^7.4.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tw-animate-css": "^1.2.4"
|
"tw-animate-css": "^1.2.4",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|
@ -3051,9 +3052,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3068,9 +3066,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3085,9 +3080,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3102,9 +3094,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3119,9 +3108,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3136,9 +3122,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3153,9 +3136,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3170,9 +3150,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3187,9 +3164,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3204,9 +3178,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3221,9 +3192,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3238,9 +3206,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3255,9 +3220,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3490,9 +3452,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3510,9 +3469,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3530,9 +3486,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3550,9 +3503,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5488,9 +5438,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5512,9 +5459,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5536,9 +5480,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5560,9 +5501,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -6942,6 +6880,34 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||||
|
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@
|
||||||
"react-router-dom": "^7.4.0",
|
"react-router-dom": "^7.4.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tw-animate-css": "^1.2.4"
|
"tw-animate-css": "^1.2.4",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|
|
||||||
16
src/App.tsx
16
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { AppLayout } from "@/components/layout/AppLayout";
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { BookingDetailPage } from "@/pages/BookingDetailPage";
|
import { BookingDetailPage } from "@/pages/BookingDetailPage";
|
||||||
import { BookingsPage } from "@/pages/BookingsPage";
|
import { BookingsPage } from "@/pages/BookingsPage";
|
||||||
import { CalendarPage } from "@/pages/CalendarPage";
|
import { CalendarPage } from "@/pages/CalendarPage";
|
||||||
|
|
@ -17,10 +17,19 @@ import { RoomsPage } from "@/pages/RoomsPage";
|
||||||
import { SettingsPage } from "@/pages/SettingsPage";
|
import { SettingsPage } from "@/pages/SettingsPage";
|
||||||
import { TransactionsPage } from "@/pages/TransactionsPage";
|
import { TransactionsPage } from "@/pages/TransactionsPage";
|
||||||
import { VisitsPage } from "@/pages/VisitsPage";
|
import { VisitsPage } from "@/pages/VisitsPage";
|
||||||
|
import { ManageUsersPage } from "@/pages/ManageUsersPage";
|
||||||
|
|
||||||
function ProtectedLayout() {
|
function ProtectedLayout() {
|
||||||
const { role } = useAuth();
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
if (!role) return <Navigate to="/login" replace />;
|
const bootstrapped = useAuthStore((s) => s.bootstrapped);
|
||||||
|
if (!bootstrapped) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!accessToken) return <Navigate to="/login" replace />;
|
||||||
return <AppLayout />;
|
return <AppLayout />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +52,7 @@ export default function App() {
|
||||||
<Route path="/marketing/visits" element={<VisitsPage />} />
|
<Route path="/marketing/visits" element={<VisitsPage />} />
|
||||||
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
||||||
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
||||||
|
<Route path="/settings/users" element={<ManageUsersPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { format } from "date-fns";
|
import { LogOut } from "lucide-react";
|
||||||
import { LogOut, Sparkles } from "lucide-react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,35 +19,58 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { name, property, setProperty, logout, role } = useAuth();
|
const { name, selectedPropertyId, logout, role } = useAuth();
|
||||||
|
const properties = useAuthStore((s) => s.properties);
|
||||||
|
const setSelectedPropertyId = useAuthStore((s) => s.setSelectedPropertyId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b bg-card/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-card/80 lg:px-6">
|
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b bg-card/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-card/80 lg:px-6">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<Select value={property} onValueChange={setProperty}>
|
{role === "ADMIN" ? (
|
||||||
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
|
<><Select
|
||||||
<SelectValue />
|
value={selectedPropertyId ?? undefined}
|
||||||
</SelectTrigger>
|
onValueChange={setSelectedPropertyId}
|
||||||
<SelectContent>
|
disabled={properties.length === 0}
|
||||||
<SelectItem value="Shitaye Suite Hotel">
|
>
|
||||||
Shitaye Suite Hotel
|
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
|
||||||
</SelectItem>
|
<SelectValue
|
||||||
<SelectItem value="Serenity Cove (demo)">
|
placeholder={properties.length === 0
|
||||||
Serenity Cove (demo)
|
? "No hotel property"
|
||||||
</SelectItem>
|
: "Select property"} />
|
||||||
</SelectContent>
|
</SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
<p className="hidden text-sm text-muted-foreground md:block">
|
{properties.map((p) => (
|
||||||
{format(new Date(), "MMMM d, yyyy")}
|
<SelectItem key={p.id} value={p.id}>
|
||||||
</p>
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select><p className="hidden text-sm text-muted-foreground md:block">
|
||||||
|
{format(new Date(), "MMMM d, yyyy")}
|
||||||
|
</p></>
|
||||||
|
) : (
|
||||||
|
<><div className="h-9 px-3 py-2 text-sm border rounded-md bg-muted/50 cursor-default select-none flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
{useAuthStore
|
||||||
|
.getState()
|
||||||
|
.properties.find((p) => p.id === selectedPropertyId)?.name ||
|
||||||
|
"No property"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="hidden text-sm text-muted-foreground md:block">
|
||||||
|
{format(new Date(), "MMMM d, yyyy")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" className="hidden sm:inline-flex">
|
{/* <Button variant="outline" size="sm" className="hidden sm:inline-flex">
|
||||||
<Sparkles className="size-4" />
|
<Sparkles className="size-4" />
|
||||||
AI assistant
|
AI assistant
|
||||||
</Button>
|
</Button> */}
|
||||||
<Button size="sm" className="hidden sm:inline-flex" asChild>
|
<Button size="sm" className="hidden sm:inline-flex" asChild>
|
||||||
<Link to="/bookings/new">+ New reservation</Link>
|
<Link to="/bookings/new">+ New reservation</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
|
|
@ -36,18 +38,29 @@ const buttonVariants = cva(
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={loading || disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{asChild ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
22
src/components/ui/spinner.tsx
Normal file
22
src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ size = 16, className, ...props }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={cn("flex items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
className="animate-spin text-muted-foreground"
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,61 +1,87 @@
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { AdminRole } from "@/lib/types";
|
import type { AdminRole } from "@/lib/types";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthContextValue {
|
||||||
role: AdminRole | null;
|
role: AdminRole | null;
|
||||||
name: string;
|
name: string;
|
||||||
property: string;
|
selectedPropertyId: string | null;
|
||||||
}
|
selectedPropertyName: string;
|
||||||
|
setSelectedPropertyId: (id: string) => void;
|
||||||
interface AuthContextValue extends AuthState {
|
|
||||||
setRole: (r: AdminRole) => void;
|
|
||||||
setProperty: (p: string) => void;
|
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
canManageCodes: boolean;
|
canManageCodes: boolean;
|
||||||
canRefund: boolean;
|
canRefund: boolean;
|
||||||
canEditBookings: boolean;
|
canEditBookings: boolean;
|
||||||
|
accessToken: string | null;
|
||||||
|
bootstrapped: boolean;
|
||||||
|
hasHotelProperty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [role, setRoleState] = useState<AdminRole | null>(null);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const [name] = useState("Sophia Mitchell");
|
const user = useAuthStore((s) => s.user);
|
||||||
const [property, setProperty] = useState("Shitaye Suite Hotel");
|
const adminRole = useAuthStore((s) => s.adminRole);
|
||||||
|
const properties = useAuthStore((s) => s.properties);
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
const bootstrapped = useAuthStore((s) => s.bootstrapped);
|
||||||
|
const setSelectedPropertyId = useAuthStore((s) => s.setSelectedPropertyId);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
const setRole = useCallback((r: AdminRole) => setRoleState(r), []);
|
useEffect(() => {
|
||||||
|
const finish = () => {
|
||||||
|
void useAuthStore.getState().bootstrap();
|
||||||
|
};
|
||||||
|
const unsub = useAuthStore.persist.onFinishHydration(finish);
|
||||||
|
if (useAuthStore.persist.hasHydrated()) finish();
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => setRoleState(null), []);
|
const selectedPropertyName = useMemo(() => {
|
||||||
|
if (!selectedPropertyId) return "No property";
|
||||||
|
return properties.find((p) => p.id === selectedPropertyId)?.name ?? "Property";
|
||||||
|
}, [properties, selectedPropertyId]);
|
||||||
|
|
||||||
|
const role = adminRole;
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
role,
|
role,
|
||||||
name,
|
name: user?.name ?? "",
|
||||||
property,
|
selectedPropertyId,
|
||||||
setRole,
|
selectedPropertyName,
|
||||||
setProperty,
|
setSelectedPropertyId,
|
||||||
logout,
|
logout,
|
||||||
canManageCodes: role === "finance" || role === "superadmin",
|
canManageCodes: role === "finance" || role === "ADMIN",
|
||||||
canRefund: role === "finance" || role === "superadmin",
|
canRefund: role === "finance" || role === "ADMIN",
|
||||||
canEditBookings:
|
canEditBookings:
|
||||||
role === "front_desk" ||
|
role === "front_desk" || role === "finance" || role === "ADMIN",
|
||||||
role === "finance" ||
|
accessToken,
|
||||||
role === "superadmin",
|
bootstrapped,
|
||||||
|
hasHotelProperty: properties.length > 0,
|
||||||
}),
|
}),
|
||||||
[role, name, property, setRole, setProperty, logout]
|
[
|
||||||
|
role,
|
||||||
|
user?.name,
|
||||||
|
selectedPropertyId,
|
||||||
|
selectedPropertyName,
|
||||||
|
setSelectedPropertyId,
|
||||||
|
logout,
|
||||||
|
accessToken,
|
||||||
|
bootstrapped,
|
||||||
|
properties.length,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|
|
||||||
123
src/lib/api.ts
123
src/lib/api.ts
|
|
@ -1,33 +1,110 @@
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
import { parseApiError } from '@/lib/auth-api';
|
||||||
const res = await fetch(`/api${path}`);
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
const API_PREFIX = '/api';
|
||||||
return res.json() as Promise<T>;
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
let getToken: () => string | null = () => null;
|
||||||
const res = await fetch(`/api${path}`, {
|
let getPropertyId: () => string | null = () => null;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
/** Register token + property scope for hotel API paths (call once from the auth store module). */
|
||||||
body: JSON.stringify(body),
|
export function registerHotelApiContext(ctx: {
|
||||||
});
|
getToken: () => string | null;
|
||||||
if (!res.ok) {
|
getPropertyId: () => string | null;
|
||||||
const t = await res.text();
|
}) {
|
||||||
throw new Error(t);
|
getToken = ctx.getToken;
|
||||||
|
getPropertyId = ctx.getPropertyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paths that stay unscoped (mock-only or non-hotel). */
|
||||||
|
function shouldRewriteForHotel(path: string): boolean {
|
||||||
|
const first = path.split('?')[0];
|
||||||
|
if (first.startsWith('/auth')) return false;
|
||||||
|
if (first.startsWith('/properties')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteHotelPath(path: string): string {
|
||||||
|
const pid = getPropertyId();
|
||||||
|
if (!pid || !shouldRewriteForHotel(path)) return path;
|
||||||
|
|
||||||
|
const [pathname, query] = path.split('?');
|
||||||
|
const q = query ? `?${query}` : '';
|
||||||
|
|
||||||
|
if (pathname === '/dashboard') {
|
||||||
|
return `/properties/${pid}/hotel/dashboard/summary${q}`;
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>;
|
|
||||||
|
return `/properties/${pid}/hotel${pathname}${q}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
async function request(
|
||||||
const res = await fetch(`/api${path}`, {
|
method: string,
|
||||||
method: "PATCH",
|
path: string,
|
||||||
headers: { "Content-Type": "application/json" },
|
body?: unknown,
|
||||||
body: JSON.stringify(body),
|
init?: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
const token = getToken();
|
||||||
|
const resolved = rewriteHotelPath(path);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(init?.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const url = `${getBaseUrl()}${API_PREFIX}${resolved}`;
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : init?.body,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await request('GET', path, undefined, init);
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path: string): Promise<void> {
|
export async function apiPost<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, { method: "DELETE" });
|
const res = await request('POST', path, body, init);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await request('PATCH', path, body, init);
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(path: string, init?: RequestInit): Promise<void> {
|
||||||
|
const res = await request('DELETE', path, undefined, init);
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated binary download (e.g. CSV export). */
|
||||||
|
export async function apiDownloadBlob(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<{ blob: Blob; filename: string | undefined }> {
|
||||||
|
const token = getToken();
|
||||||
|
const resolved = rewriteHotelPath(path);
|
||||||
|
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const url = `${getBaseUrl()}${API_PREFIX}${resolved}`;
|
||||||
|
const res = await fetch(url, { ...init, method: 'GET', headers });
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
|
||||||
|
const cd = res.headers.get('Content-Disposition');
|
||||||
|
let filename: string | undefined;
|
||||||
|
const m = cd?.match(/filename="?([^";]+)"?/);
|
||||||
|
if (m) filename = m[1];
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
return { blob, filename };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
src/lib/auth-api.ts
Normal file
144
src/lib/auth-api.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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). */
|
||||||
|
export function registerHotelAuthApiContext(ctx: {
|
||||||
|
getPropertyId: () => string | null;
|
||||||
|
}) {
|
||||||
|
getPropertyId = ctx.getPropertyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRewriteForHotel(path: string): boolean {
|
||||||
|
const first = path.split("?")[0];
|
||||||
|
if (first.startsWith("/auth")) return false;
|
||||||
|
if (first.startsWith("/properties")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteHotelPath(path: string): string {
|
||||||
|
const pid = getPropertyId();
|
||||||
|
if (!pid || !shouldRewriteForHotel(path)) return path;
|
||||||
|
|
||||||
|
const [pathname, query] = path.split("?");
|
||||||
|
const q = query ? `?${query}` : "";
|
||||||
|
return `/properties/${pid}/hotel${pathname}${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiUrl(path: string): string {
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
const resolved = rewriteHotelPath(path);
|
||||||
|
return `${base}${API_ROOT}${resolved}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseApiError(res: Response): Promise<string> {
|
||||||
|
const t = await res.text();
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(t) as { message?: string | string[]; error?: string };
|
||||||
|
if (Array.isArray(j.message)) return j.message.join(", ");
|
||||||
|
if (typeof j.message === "string") return j.message;
|
||||||
|
if (j.error) return j.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return t || res.statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
role: string;
|
||||||
|
status?: string;
|
||||||
|
propertyId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function postLogin(identifier: string, password: string) {
|
||||||
|
const res = await fetch(apiUrl("/auth/login"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identifier, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postLoginPhoneRequest(phone: string) {
|
||||||
|
const res = await fetch(apiUrl("/auth/login-phone-request"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<{
|
||||||
|
message: string;
|
||||||
|
loginRequestToken?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postLoginPhoneVerify(loginRequestToken: string, otp: string) {
|
||||||
|
const res = await fetch(apiUrl("/auth/login-phone-verify"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ loginRequestToken, otp }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PropertyRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accommodationMode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getProfile(token: string) {
|
||||||
|
const res = await fetch(apiUrl("/auth/profile"), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<AuthUser & Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProperties(token: string) {
|
||||||
|
const res = await fetch(apiUrl("/properties/hotels"), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<PropertyRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaffAccess(token: string) {
|
||||||
|
const res = await fetch(apiUrl("/staff-access"), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data || data as StaffAccess[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function postStaff(token: string, dto: RegisterHotelStaffDto) {
|
||||||
|
const res = await fetch(apiUrl("/staff"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return res.json() as Promise<StaffAccess>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStaffAccess(token: string, accessId: string) {
|
||||||
|
const res = await fetch(apiUrl(`/staff-access/${accessId}`), {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
171
src/lib/hotel-adapters.ts
Normal file
171
src/lib/hotel-adapters.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import type { Booking, BookingStatus, Room, RoomInventoryStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
export type ApiHotelRoom = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roomType: string;
|
||||||
|
maxGuests: number;
|
||||||
|
baseRate: string | number;
|
||||||
|
operationalStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiHotelCustomer = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiHotelBookingRow = {
|
||||||
|
id: string;
|
||||||
|
roomId: string;
|
||||||
|
customerId: string;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
guestCount?: number;
|
||||||
|
status: string;
|
||||||
|
totalPrice?: string | number | null;
|
||||||
|
currency?: string;
|
||||||
|
discountCode?: string | null;
|
||||||
|
referralCode?: string | null;
|
||||||
|
flightPnr?: string | null;
|
||||||
|
arrivalTime?: string | null;
|
||||||
|
payLaterHold?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
internalNotes?: unknown;
|
||||||
|
customer: ApiHotelCustomer;
|
||||||
|
room?: { id: string; name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapOperationalStatus(s: string): RoomInventoryStatus {
|
||||||
|
switch (s) {
|
||||||
|
case "VACANT":
|
||||||
|
return "available";
|
||||||
|
case "OCCUPIED":
|
||||||
|
return "occupied";
|
||||||
|
case "NOT_READY":
|
||||||
|
return "maintenance";
|
||||||
|
default:
|
||||||
|
return "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiRoomToRoom(r: ApiHotelRoom): Room {
|
||||||
|
const rate =
|
||||||
|
typeof r.baseRate === "string" ? parseFloat(r.baseRate) : Number(r.baseRate);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
roomTypeSlug: r.roomType,
|
||||||
|
maxGuests: r.maxGuests,
|
||||||
|
baseRate: Number.isFinite(rate) ? rate : 0,
|
||||||
|
status: mapOperationalStatus(r.operationalStatus),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHotelBookingStatus(s: string): BookingStatus {
|
||||||
|
switch (s) {
|
||||||
|
case "HOLD":
|
||||||
|
return "held";
|
||||||
|
case "CONFIRMED":
|
||||||
|
case "CHECKED_IN":
|
||||||
|
case "CHECKED_OUT":
|
||||||
|
return "confirmed";
|
||||||
|
case "CANCELLED":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "draft";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nightsBetween(checkIn: string, checkOut: string): number {
|
||||||
|
const a = new Date(checkIn.slice(0, 10)).getTime();
|
||||||
|
const b = new Date(checkOut.slice(0, 10)).getTime();
|
||||||
|
const n = Math.round((b - a) / 86400000);
|
||||||
|
return Math.max(1, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringNotes(raw: unknown): string[] | undefined {
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const out = raw.filter((x): x is string => typeof x === "string");
|
||||||
|
return out.length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiBookingToBooking(row: ApiHotelBookingRow): Booking {
|
||||||
|
const c = row.customer;
|
||||||
|
const checkIn = row.checkIn.slice(0, 10);
|
||||||
|
const checkOut = row.checkOut.slice(0, 10);
|
||||||
|
const nights = nightsBetween(checkIn, checkOut);
|
||||||
|
const totalRaw = row.totalPrice;
|
||||||
|
const total =
|
||||||
|
totalRaw === undefined || totalRaw === null
|
||||||
|
? 0
|
||||||
|
: typeof totalRaw === "string"
|
||||||
|
? parseFloat(totalRaw)
|
||||||
|
: Number(totalRaw);
|
||||||
|
const safeTotal = Number.isFinite(total) ? total : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
guest: {
|
||||||
|
firstName: c.firstName,
|
||||||
|
lastName: c.lastName,
|
||||||
|
email: c.email ?? "",
|
||||||
|
phone: c.phone ?? "",
|
||||||
|
flightBookingNumber: row.flightPnr ?? "",
|
||||||
|
arrivalTime: row.arrivalTime ?? "",
|
||||||
|
},
|
||||||
|
checkIn,
|
||||||
|
checkOut,
|
||||||
|
guests: row.guestCount ?? 1,
|
||||||
|
roomId: row.roomId,
|
||||||
|
nights,
|
||||||
|
pricing: {
|
||||||
|
nightlySubtotal: safeTotal,
|
||||||
|
discountPercent: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
total: safeTotal,
|
||||||
|
totalCents: Math.round(safeTotal * 100),
|
||||||
|
currency: row.currency ?? "ETB",
|
||||||
|
couponCode: row.discountCode ?? undefined,
|
||||||
|
},
|
||||||
|
status: mapHotelBookingStatus(row.status),
|
||||||
|
payLaterHold: row.payLaterHold ?? false,
|
||||||
|
referralCode: row.referralCode ?? undefined,
|
||||||
|
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||||
|
updatedAt: row.updatedAt ?? new Date().toISOString(),
|
||||||
|
roomDisplayLabel: row.room?.name,
|
||||||
|
internalNotes: asStringNotes(row.internalNotes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLikelyApiHotelBooking(row: unknown): row is ApiHotelBookingRow {
|
||||||
|
if (!row || typeof row !== "object") return false;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof r.customer === "object" &&
|
||||||
|
r.customer !== null &&
|
||||||
|
typeof (r.customer as ApiHotelCustomer).firstName === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLikelyApiHotelRoom(row: unknown): row is ApiHotelRoom {
|
||||||
|
if (!row || typeof row !== "object") return false;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
return typeof r.operationalStatus === "string" && typeof r.roomType === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map UI filter to backend HotelBookingStatus query param. */
|
||||||
|
export function bookingStatusToQuery(status: string): string | undefined {
|
||||||
|
if (status === "all") return undefined;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
confirmed: "CONFIRMED",
|
||||||
|
held: "HOLD",
|
||||||
|
cancelled: "CANCELLED",
|
||||||
|
payment_pending: "HOLD",
|
||||||
|
};
|
||||||
|
return map[status] ?? status.toUpperCase();
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ export type AdminRole =
|
||||||
| "viewer"
|
| "viewer"
|
||||||
| "front_desk"
|
| "front_desk"
|
||||||
| "finance"
|
| "finance"
|
||||||
| "superadmin";
|
| "ADMIN";
|
||||||
|
|
||||||
export type BookingStatus =
|
export type BookingStatus =
|
||||||
| "draft"
|
| "draft"
|
||||||
|
|
@ -27,10 +27,9 @@ export type RoomInventoryStatus =
|
||||||
| "out_of_order";
|
| "out_of_order";
|
||||||
|
|
||||||
export type RoomBlockReason =
|
export type RoomBlockReason =
|
||||||
| "maintenance"
|
| "MAINTENANCE"
|
||||||
| "owner_hold"
|
| "OWNER_BLOCK"
|
||||||
| "closed"
|
| "OTHER";
|
||||||
| "other";
|
|
||||||
|
|
||||||
export type DiscountType = "percent" | "fixed_amount";
|
export type DiscountType = "percent" | "fixed_amount";
|
||||||
|
|
||||||
|
|
@ -58,6 +57,8 @@ export interface PricingLine {
|
||||||
|
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** When loaded from the hotel API, used instead of inferring label from room id */
|
||||||
|
roomDisplayLabel?: string;
|
||||||
guest: GuestDetails;
|
guest: GuestDetails;
|
||||||
checkIn: string;
|
checkIn: string;
|
||||||
checkOut: string;
|
checkOut: string;
|
||||||
|
|
@ -157,13 +158,8 @@ export interface DiscountCode {
|
||||||
|
|
||||||
export interface ReferralCode {
|
export interface ReferralCode {
|
||||||
id: string;
|
id: string;
|
||||||
|
meta: string;
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
|
||||||
attributedTo?: string;
|
|
||||||
validFrom: string;
|
|
||||||
validTo: string;
|
|
||||||
maxRedemptions: number | null;
|
|
||||||
redemptionCount: number;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +188,33 @@ export interface DashboardPayload {
|
||||||
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
||||||
revenueExtras: { label: string; current: number; target: number }[];
|
revenueExtras: { label: string; current: number; target: number }[];
|
||||||
rating: { score: number; label: string; imageUrl?: string };
|
rating: { score: number; label: string; imageUrl?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum HotelStaffRole {
|
||||||
|
FRONT_DESK = "FRONT_DESK",
|
||||||
|
FINANCE = "FINANCE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterHotelStaffDto {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
password: string;
|
||||||
|
hotelRole: HotelStaffRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffAccess {
|
||||||
|
id: string;
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: HotelStaffRole;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPayload {
|
||||||
recentBookings: Booking[];
|
recentBookings: Booking[];
|
||||||
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
||||||
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
||||||
|
|
|
||||||
19
src/main.tsx
19
src/main.tsx
|
|
@ -9,15 +9,16 @@ import App from "@/App";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
async function enableMocking() {
|
// async function enableMocking() {
|
||||||
if (import.meta.env.MODE !== "development") return;
|
// if (import.meta.env.MODE !== "development") return;
|
||||||
const { worker } = await import("@/mocks/browser");
|
// if (import.meta.env.VITE_MSW === "false") return;
|
||||||
await worker.start({
|
// const { worker } = await import("@/mocks/browser");
|
||||||
onUnhandledRequest: "bypass",
|
// await worker.start({
|
||||||
});
|
// onUnhandledRequest: "bypass",
|
||||||
}
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
void enableMocking().then(() => {
|
// void enableMocking().then(() => {
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
@ -27,4 +28,4 @@ void enableMocking().then(() => {
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
});
|
// });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { setupWorker } from "msw/browser";
|
|
||||||
|
|
||||||
import { handlers } from "@/mocks/handlers";
|
|
||||||
|
|
||||||
export const worker = setupWorker(...handlers);
|
|
||||||
120
src/mocks/db.ts
120
src/mocks/db.ts
|
|
@ -1,120 +0,0 @@
|
||||||
import type {
|
|
||||||
Booking,
|
|
||||||
BookingStatus,
|
|
||||||
DiscountCode,
|
|
||||||
Payment,
|
|
||||||
ReferralCode,
|
|
||||||
Room,
|
|
||||||
RoomBlock,
|
|
||||||
SiteVisit,
|
|
||||||
Transaction,
|
|
||||||
} from "@/lib/types";
|
|
||||||
import {
|
|
||||||
seedBlocks,
|
|
||||||
seedBookings,
|
|
||||||
seedDiscountCodes,
|
|
||||||
seedPayments,
|
|
||||||
seedReferralCodes,
|
|
||||||
seedRooms,
|
|
||||||
seedSiteVisits,
|
|
||||||
seedTransactions,
|
|
||||||
} from "@/mocks/seed";
|
|
||||||
|
|
||||||
function deepClone<T>(x: T): T {
|
|
||||||
return JSON.parse(JSON.stringify(x)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockDb {
|
|
||||||
bookings: Booking[];
|
|
||||||
rooms: Room[];
|
|
||||||
blocks: RoomBlock[];
|
|
||||||
payments: Payment[];
|
|
||||||
transactions: Transaction[];
|
|
||||||
discountCodes: DiscountCode[];
|
|
||||||
referralCodes: ReferralCode[];
|
|
||||||
siteVisits: SiteVisit[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let db: MockDb = createFreshDb();
|
|
||||||
|
|
||||||
function createFreshDb(): MockDb {
|
|
||||||
return {
|
|
||||||
bookings: deepClone(seedBookings),
|
|
||||||
rooms: deepClone(seedRooms),
|
|
||||||
blocks: deepClone(seedBlocks),
|
|
||||||
payments: deepClone(seedPayments),
|
|
||||||
transactions: deepClone(seedTransactions),
|
|
||||||
discountCodes: deepClone(seedDiscountCodes),
|
|
||||||
referralCodes: deepClone(seedReferralCodes),
|
|
||||||
siteVisits: deepClone(seedSiteVisits),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetDb() {
|
|
||||||
db = createFreshDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDb(): MockDb {
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inclusive start, exclusive end for nights (hotel convention) */
|
|
||||||
export function parseYmd(s: string) {
|
|
||||||
const [y, m, d] = s.split("-").map(Number);
|
|
||||||
return new Date(y, m - 1, d);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rangesOverlap(
|
|
||||||
aStart: string,
|
|
||||||
aEnd: string,
|
|
||||||
bStart: string,
|
|
||||||
bEnd: string
|
|
||||||
) {
|
|
||||||
const as = parseYmd(aStart).getTime();
|
|
||||||
const ae = parseYmd(aEnd).getTime();
|
|
||||||
const bs = parseYmd(bStart).getTime();
|
|
||||||
const be = parseYmd(bEnd).getTime();
|
|
||||||
return as < be && bs < ae;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bookingConflicts(
|
|
||||||
roomId: string,
|
|
||||||
checkIn: string,
|
|
||||||
checkOut: string,
|
|
||||||
excludeId?: string
|
|
||||||
): boolean {
|
|
||||||
const active: BookingStatus[] = [
|
|
||||||
"held",
|
|
||||||
"payment_pending",
|
|
||||||
"confirmed",
|
|
||||||
];
|
|
||||||
return db.bookings.some(
|
|
||||||
(b) =>
|
|
||||||
b.roomId === roomId &&
|
|
||||||
active.includes(b.status) &&
|
|
||||||
b.id !== excludeId &&
|
|
||||||
rangesOverlap(checkIn, checkOut, b.checkIn, b.checkOut)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blockConflicts(
|
|
||||||
roomId: string,
|
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
excludeId?: string
|
|
||||||
) {
|
|
||||||
return db.blocks.some(
|
|
||||||
(blk) =>
|
|
||||||
blk.roomId === roomId &&
|
|
||||||
blk.id !== excludeId &&
|
|
||||||
rangesOverlap(start, end, blk.startDate, blk.endDate)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateId(prefix: string) {
|
|
||||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCode(code: string) {
|
|
||||||
return code.trim().toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
@ -1,566 +0,0 @@
|
||||||
import { addDays, format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval } from "date-fns";
|
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
|
|
||||||
import { TAX_RATE } from "@/lib/constants";
|
|
||||||
import type { Booking, CustomerRow, DashboardPayload } from "@/lib/types";
|
|
||||||
import {
|
|
||||||
bookingConflicts,
|
|
||||||
blockConflicts,
|
|
||||||
generateId,
|
|
||||||
getDb,
|
|
||||||
normalizeCode,
|
|
||||||
parseYmd,
|
|
||||||
rangesOverlap,
|
|
||||||
} from "@/mocks/db";
|
|
||||||
|
|
||||||
const API = "/api";
|
|
||||||
|
|
||||||
export const handlers = [
|
|
||||||
http.get(`${API}/bookings`, ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const status = url.searchParams.get("status");
|
|
||||||
const roomId = url.searchParams.get("roomId");
|
|
||||||
const email = url.searchParams.get("email");
|
|
||||||
const q = url.searchParams.get("q")?.toLowerCase();
|
|
||||||
const discountCode = url.searchParams.get("discountCode");
|
|
||||||
const referralCode = url.searchParams.get("referralCode");
|
|
||||||
let list = [...getDb().bookings];
|
|
||||||
if (status) list = list.filter((b) => b.status === status);
|
|
||||||
if (roomId) list = list.filter((b) => b.roomId === roomId);
|
|
||||||
if (email) list = list.filter((b) => b.guest.email === email);
|
|
||||||
if (discountCode)
|
|
||||||
list = list.filter(
|
|
||||||
(b) =>
|
|
||||||
b.pricing.couponCode?.toUpperCase() === discountCode.toUpperCase()
|
|
||||||
);
|
|
||||||
if (referralCode)
|
|
||||||
list = list.filter(
|
|
||||||
(b) => b.referralCode?.toUpperCase() === referralCode.toUpperCase()
|
|
||||||
);
|
|
||||||
if (q) {
|
|
||||||
list = list.filter(
|
|
||||||
(b) =>
|
|
||||||
`${b.guest.firstName} ${b.guest.lastName}`
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(q) ||
|
|
||||||
b.guest.email.toLowerCase().includes(q) ||
|
|
||||||
b.holdReference?.toLowerCase().includes(q) ||
|
|
||||||
b.confirmationId?.toLowerCase().includes(q) ||
|
|
||||||
b.id.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return HttpResponse.json({ data: list });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/bookings/:id`, ({ params }) => {
|
|
||||||
const b = getDb().bookings.find((x) => x.id === params.id);
|
|
||||||
if (!b) return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
return HttpResponse.json(b);
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.patch(`${API}/bookings/:id`, async ({ params, request }) => {
|
|
||||||
const body = (await request.json()) as Partial<Booking>;
|
|
||||||
const db = getDb();
|
|
||||||
const idx = db.bookings.findIndex((x) => x.id === params.id);
|
|
||||||
if (idx === -1)
|
|
||||||
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
const cur = db.bookings[idx];
|
|
||||||
const next = { ...cur, ...body, updatedAt: new Date().toISOString() };
|
|
||||||
if (body.internalNotes) {
|
|
||||||
next.internalNotes = [
|
|
||||||
...(cur.internalNotes ?? []),
|
|
||||||
...body.internalNotes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
db.bookings[idx] = next;
|
|
||||||
return HttpResponse.json(next);
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.post(`${API}/bookings`, async ({ request }) => {
|
|
||||||
const body = (await request.json()) as Partial<Booking> & {
|
|
||||||
guest: Booking["guest"];
|
|
||||||
checkIn: string;
|
|
||||||
checkOut: string;
|
|
||||||
roomId: string;
|
|
||||||
guests: number;
|
|
||||||
};
|
|
||||||
const db = getDb();
|
|
||||||
if (
|
|
||||||
bookingConflicts(body.roomId!, body.checkIn!, body.checkOut!)
|
|
||||||
) {
|
|
||||||
return HttpResponse.json(
|
|
||||||
{ error: "Room unavailable for these dates" },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ci = parseYmd(body.checkIn!);
|
|
||||||
const co = parseYmd(body.checkOut!);
|
|
||||||
const nights = Math.max(
|
|
||||||
1,
|
|
||||||
Math.round((co.getTime() - ci.getTime()) / 86400000)
|
|
||||||
);
|
|
||||||
const room = db.rooms.find((r) => r.id === body.roomId);
|
|
||||||
const nightly = room?.baseRate ?? 120;
|
|
||||||
const sub = nightly * nights;
|
|
||||||
let discountPct = 0;
|
|
||||||
let coupon: string | undefined;
|
|
||||||
if (body.pricing?.couponCode) {
|
|
||||||
const dc = db.discountCodes.find(
|
|
||||||
(d) =>
|
|
||||||
normalizeCode(d.code) === normalizeCode(body.pricing!.couponCode!) &&
|
|
||||||
d.isActive
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
dc &&
|
|
||||||
dc.discountType === "percent" &&
|
|
||||||
(!dc.maxRedemptions || dc.redemptionCount < dc.maxRedemptions)
|
|
||||||
) {
|
|
||||||
discountPct = dc.value;
|
|
||||||
coupon = dc.code;
|
|
||||||
dc.redemptionCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const discountAmount = sub * (discountPct / 100);
|
|
||||||
const after = sub - discountAmount;
|
|
||||||
const taxAmount = after * TAX_RATE;
|
|
||||||
const total = after + taxAmount;
|
|
||||||
const id = generateId("b");
|
|
||||||
const booking: Booking = {
|
|
||||||
id,
|
|
||||||
guest: body.guest,
|
|
||||||
checkIn: body.checkIn!,
|
|
||||||
checkOut: body.checkOut!,
|
|
||||||
guests: body.guests ?? 2,
|
|
||||||
roomId: body.roomId!,
|
|
||||||
nights,
|
|
||||||
status: body.status ?? "confirmed",
|
|
||||||
payLaterHold: body.payLaterHold ?? false,
|
|
||||||
holdReference: `SHY-${id.slice(-6).toUpperCase()}`,
|
|
||||||
pricing: {
|
|
||||||
nightlySubtotal: sub,
|
|
||||||
couponCode: coupon,
|
|
||||||
discountPercent: discountPct,
|
|
||||||
discountAmount,
|
|
||||||
taxRate: TAX_RATE,
|
|
||||||
taxAmount,
|
|
||||||
total,
|
|
||||||
totalCents: Math.round(total * 100),
|
|
||||||
currency: "USD",
|
|
||||||
},
|
|
||||||
referralCode: body.referralCode,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
if (body.referralCode) {
|
|
||||||
const rc = db.referralCodes.find(
|
|
||||||
(r) => normalizeCode(r.code) === normalizeCode(body.referralCode!)
|
|
||||||
);
|
|
||||||
if (rc && rc.isActive) rc.redemptionCount += 1;
|
|
||||||
}
|
|
||||||
db.bookings.push(booking);
|
|
||||||
return HttpResponse.json(booking, { status: 201 });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/payments`, () =>
|
|
||||||
HttpResponse.json({ data: getDb().payments })
|
|
||||||
),
|
|
||||||
|
|
||||||
http.get(`${API}/transactions`, ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const bookingId = url.searchParams.get("bookingId");
|
|
||||||
let t = [...getDb().transactions];
|
|
||||||
if (bookingId) t = t.filter((x) => x.bookingId === bookingId);
|
|
||||||
t.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
||||||
);
|
|
||||||
return HttpResponse.json({ data: t });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/rooms`, () =>
|
|
||||||
HttpResponse.json({ data: getDb().rooms })
|
|
||||||
),
|
|
||||||
|
|
||||||
http.post(`${API}/rooms`, async ({ request }) => {
|
|
||||||
const body = (await request.json()) as Omit<
|
|
||||||
import("@/lib/types").Room,
|
|
||||||
"id"
|
|
||||||
>;
|
|
||||||
const db = getDb();
|
|
||||||
const room = { ...body, id: generateId("r") };
|
|
||||||
db.rooms.push(room);
|
|
||||||
return HttpResponse.json(room, { status: 201 });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.patch(`${API}/rooms/:id`, async ({ params, request }) => {
|
|
||||||
const body = (await request.json()) as Partial<import("@/lib/types").Room>;
|
|
||||||
const db = getDb();
|
|
||||||
const idx = db.rooms.findIndex((r) => r.id === params.id);
|
|
||||||
if (idx === -1)
|
|
||||||
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
db.rooms[idx] = { ...db.rooms[idx], ...body };
|
|
||||||
return HttpResponse.json(db.rooms[idx]);
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/rooms/:id/blocks`, ({ params }) => {
|
|
||||||
const list = getDb().blocks.filter((b) => b.roomId === params.id);
|
|
||||||
return HttpResponse.json({ data: list });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.post(`${API}/rooms/:id/blocks`, async ({ params, request }) => {
|
|
||||||
const body = (await request.json()) as Omit<
|
|
||||||
import("@/lib/types").RoomBlock,
|
|
||||||
"id" | "roomId" | "createdAt"
|
|
||||||
>;
|
|
||||||
const db = getDb();
|
|
||||||
if (blockConflicts(params.id as string, body.startDate, body.endDate)) {
|
|
||||||
return HttpResponse.json(
|
|
||||||
{ error: "Overlapping block exists" },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const blk: import("@/lib/types").RoomBlock = {
|
|
||||||
...body,
|
|
||||||
id: generateId("blk"),
|
|
||||||
roomId: params.id as string,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
db.blocks.push(blk);
|
|
||||||
return HttpResponse.json(blk, { status: 201 });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.delete(`${API}/rooms/:roomId/blocks/:blockId`, ({ params }) => {
|
|
||||||
const db = getDb();
|
|
||||||
const idx = db.blocks.findIndex(
|
|
||||||
(b) => b.id === params.blockId && b.roomId === params.roomId
|
|
||||||
);
|
|
||||||
if (idx === -1)
|
|
||||||
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
db.blocks.splice(idx, 1);
|
|
||||||
return HttpResponse.json({ ok: true });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/dashboard/summary`, () => {
|
|
||||||
const db = getDb();
|
|
||||||
const today = format(new Date(), "yyyy-MM-dd");
|
|
||||||
const arrivals = db.bookings.filter(
|
|
||||||
(b) => b.checkIn === today && ["confirmed", "held"].includes(b.status)
|
|
||||||
).length;
|
|
||||||
const departures = db.bookings.filter(
|
|
||||||
(b) => b.checkOut === today && b.status === "confirmed"
|
|
||||||
).length;
|
|
||||||
const unpaidHolds = db.bookings.filter(
|
|
||||||
(b) => b.status === "held" && b.payLaterHold
|
|
||||||
).length;
|
|
||||||
const revenue = db.bookings
|
|
||||||
.filter((b) => b.status === "confirmed")
|
|
||||||
.reduce((s, b) => s + b.pricing.total, 0);
|
|
||||||
return HttpResponse.json({
|
|
||||||
arrivals,
|
|
||||||
departures,
|
|
||||||
unpaidHolds,
|
|
||||||
revenueMonth: revenue,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/dashboard`, () => {
|
|
||||||
const db = getDb();
|
|
||||||
const payload: DashboardPayload = {
|
|
||||||
bookingSeries: Array.from({ length: 14 }, (_, i) => {
|
|
||||||
const d = addDays(new Date(), -13 + i);
|
|
||||||
return {
|
|
||||||
date: format(d, "MMM d"),
|
|
||||||
total: 20 + (i % 5) * 3,
|
|
||||||
online: 12 + (i % 4) * 2,
|
|
||||||
cancelled: i % 6 === 0 ? 2 : 0,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
visitsSeries: Array.from({ length: 14 }, (_, i) => {
|
|
||||||
const d = addDays(new Date(), -13 + i);
|
|
||||||
const key = format(d, "yyyy-MM-dd");
|
|
||||||
const dayVisits = db.siteVisits.filter((v) =>
|
|
||||||
v.occurredAt.startsWith(key)
|
|
||||||
);
|
|
||||||
const sessions = new Set(dayVisits.map((v) => v.sessionId)).size;
|
|
||||||
return {
|
|
||||||
date: format(d, "MMM d"),
|
|
||||||
views: dayVisits.length * 8,
|
|
||||||
sessions: sessions || dayVisits.length,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
heatmap: db.rooms.map((r) => ({
|
|
||||||
roomId: r.id,
|
|
||||||
state:
|
|
||||||
r.status === "maintenance"
|
|
||||||
? ("not_ready" as const)
|
|
||||||
: r.status === "occupied"
|
|
||||||
? ("occupied" as const)
|
|
||||||
: r.status === "out_of_order"
|
|
||||||
? ("unavailable" as const)
|
|
||||||
: ("vacant" as const),
|
|
||||||
})),
|
|
||||||
revenueExtras: [
|
|
||||||
{ label: "Restaurant", current: 12400, target: 18000 },
|
|
||||||
{ label: "Bar", current: 8200, target: 12000 },
|
|
||||||
{ label: "Spa", current: 5600, target: 9000 },
|
|
||||||
],
|
|
||||||
rating: {
|
|
||||||
score: 4.8,
|
|
||||||
label: "Impressive",
|
|
||||||
imageUrl:
|
|
||||||
"https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=400&q=80",
|
|
||||||
},
|
|
||||||
recentBookings: [...db.bookings]
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
||||||
)
|
|
||||||
.slice(0, 5),
|
|
||||||
calendarEvents: [
|
|
||||||
{
|
|
||||||
id: "e1",
|
|
||||||
title: "VIP arrival — Suite 201",
|
|
||||||
date: format(new Date(), "yyyy-MM-dd"),
|
|
||||||
accent: "sky",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "e2",
|
|
||||||
title: "Staff training",
|
|
||||||
date: format(addDays(new Date(), 2), "yyyy-MM-dd"),
|
|
||||||
accent: "pink",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
codeStats: {
|
|
||||||
discountRedemptions: db.discountCodes.reduce(
|
|
||||||
(s, d) => s + d.redemptionCount,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
referralRedemptions: db.referralCodes.reduce(
|
|
||||||
(s, r) => s + r.redemptionCount,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return HttpResponse.json(payload);
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/reservations/timeline`, ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const month = url.searchParams.get("month") ?? format(new Date(), "yyyy-MM");
|
|
||||||
const start = startOfMonth(parseISO(`${month}-01`));
|
|
||||||
const end = endOfMonth(start);
|
|
||||||
const days = eachDayOfInterval({ start, end }).map((d) =>
|
|
||||||
format(d, "yyyy-MM-dd")
|
|
||||||
);
|
|
||||||
const db = getDb();
|
|
||||||
const segments = db.bookings
|
|
||||||
.filter((b) =>
|
|
||||||
rangesOverlap(b.checkIn, b.checkOut, format(start, "yyyy-MM-dd"), format(end, "yyyy-MM-dd"))
|
|
||||||
)
|
|
||||||
.map((b) => ({
|
|
||||||
bookingId: b.id,
|
|
||||||
guestName: `${b.guest.firstName} ${b.guest.lastName}`,
|
|
||||||
roomId: b.roomId,
|
|
||||||
start: b.checkIn,
|
|
||||||
end: b.checkOut,
|
|
||||||
status: b.status,
|
|
||||||
paymentLabel:
|
|
||||||
b.status === "confirmed"
|
|
||||||
? "Paid"
|
|
||||||
: b.status === "payment_pending"
|
|
||||||
? "Part-paid"
|
|
||||||
: "Unpaid",
|
|
||||||
source: ["Direct", "Booking.com", "Direct"][b.id.length % 3],
|
|
||||||
}));
|
|
||||||
return HttpResponse.json({ days, rooms: db.rooms, segments });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/export/bookings.csv`, () => {
|
|
||||||
const db = getDb();
|
|
||||||
const header =
|
|
||||||
"id,guest,email,checkIn,checkOut,room,status,total\n";
|
|
||||||
const rows = db.bookings
|
|
||||||
.map(
|
|
||||||
(b) =>
|
|
||||||
`${b.id},"${b.guest.firstName} ${b.guest.lastName}",${b.guest.email},${b.checkIn},${b.checkOut},${b.roomId},${b.status},${b.pricing.total}`
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
return new HttpResponse(header + rows, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/csv",
|
|
||||||
"Content-Disposition": 'attachment; filename="bookings.csv"',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/customers`, () => {
|
|
||||||
const map = new Map<string, CustomerRow>();
|
|
||||||
for (const b of getDb().bookings) {
|
|
||||||
const k = b.guest.email.toLowerCase();
|
|
||||||
const name = `${b.guest.firstName} ${b.guest.lastName}`;
|
|
||||||
const prev = map.get(k);
|
|
||||||
if (!prev) {
|
|
||||||
map.set(k, {
|
|
||||||
email: b.guest.email,
|
|
||||||
name,
|
|
||||||
bookingCount: 1,
|
|
||||||
lastStay: b.checkOut,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
prev.bookingCount += 1;
|
|
||||||
if (!prev.lastStay || b.checkOut > prev.lastStay)
|
|
||||||
prev.lastStay = b.checkOut;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return HttpResponse.json({ data: [...map.values()] });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/analytics/visits/recent`, () => {
|
|
||||||
const visits = [...getDb().siteVisits]
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()
|
|
||||||
)
|
|
||||||
.slice(0, 40);
|
|
||||||
return HttpResponse.json({ data: visits });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/analytics/visits`, ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const from = url.searchParams.get("from");
|
|
||||||
const to = url.searchParams.get("to");
|
|
||||||
let visits = [...getDb().siteVisits];
|
|
||||||
if (from) visits = visits.filter((v) => v.occurredAt >= from);
|
|
||||||
if (to) visits = visits.filter((v) => v.occurredAt <= to);
|
|
||||||
const byDay = new Map<string, { views: number; sessions: Set<string> }>();
|
|
||||||
for (const v of visits) {
|
|
||||||
const day = v.occurredAt.slice(0, 10);
|
|
||||||
if (!byDay.has(day))
|
|
||||||
byDay.set(day, { views: 0, sessions: new Set() });
|
|
||||||
const g = byDay.get(day)!;
|
|
||||||
g.views += 1;
|
|
||||||
if (v.sessionId) g.sessions.add(v.sessionId);
|
|
||||||
}
|
|
||||||
const series = [...byDay.entries()]
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([date, g]) => ({
|
|
||||||
date,
|
|
||||||
views: g.views,
|
|
||||||
sessions: g.sessions.size || g.views,
|
|
||||||
}));
|
|
||||||
return HttpResponse.json({
|
|
||||||
series,
|
|
||||||
totalViews: visits.length,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.post(`${API}/analytics/visits`, async ({ request }) => {
|
|
||||||
const body = (await request.json()) as Partial<
|
|
||||||
import("@/lib/types").SiteVisit
|
|
||||||
>;
|
|
||||||
const db = getDb();
|
|
||||||
const v: import("@/lib/types").SiteVisit = {
|
|
||||||
id: generateId("v"),
|
|
||||||
occurredAt: body.occurredAt ?? new Date().toISOString(),
|
|
||||||
path: body.path ?? "/",
|
|
||||||
referrer: body.referrer,
|
|
||||||
utmSource: body.utmSource,
|
|
||||||
utmMedium: body.utmMedium,
|
|
||||||
utmCampaign: body.utmCampaign,
|
|
||||||
sessionId: body.sessionId ?? `s-${Date.now()}`,
|
|
||||||
device: body.device,
|
|
||||||
};
|
|
||||||
db.siteVisits.push(v);
|
|
||||||
return HttpResponse.json(v, { status: 201 });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/discount-codes`, () =>
|
|
||||||
HttpResponse.json({ data: getDb().discountCodes })
|
|
||||||
),
|
|
||||||
|
|
||||||
http.post(`${API}/discount-codes`, async ({ request }) => {
|
|
||||||
const body = (await request.json()) as Partial<import("@/lib/types").DiscountCode> & {
|
|
||||||
generate?: boolean;
|
|
||||||
};
|
|
||||||
const db = getDb();
|
|
||||||
const code =
|
|
||||||
body.code && !body.generate
|
|
||||||
? normalizeCode(body.code)
|
|
||||||
: `SAVE${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
|
||||||
if (db.discountCodes.some((d) => normalizeCode(d.code) === code)) {
|
|
||||||
return HttpResponse.json({ error: "Code exists" }, { status: 409 });
|
|
||||||
}
|
|
||||||
const dc: import("@/lib/types").DiscountCode = {
|
|
||||||
id: generateId("dc"),
|
|
||||||
code,
|
|
||||||
description: body.description,
|
|
||||||
discountType: body.discountType ?? "percent",
|
|
||||||
value: body.value ?? 10,
|
|
||||||
currency: body.currency,
|
|
||||||
validFrom: body.validFrom ?? format(new Date(), "yyyy-MM-dd"),
|
|
||||||
validTo: body.validTo ?? format(addDays(new Date(), 365), "yyyy-MM-dd"),
|
|
||||||
maxRedemptions: body.maxRedemptions ?? null,
|
|
||||||
redemptionCount: 0,
|
|
||||||
isActive: body.isActive ?? true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
db.discountCodes.push(dc);
|
|
||||||
return HttpResponse.json(dc, { status: 201 });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.patch(`${API}/discount-codes/:id`, async ({ params, request }) => {
|
|
||||||
const body = (await request.json()) as Partial<
|
|
||||||
import("@/lib/types").DiscountCode
|
|
||||||
>;
|
|
||||||
const db = getDb();
|
|
||||||
const idx = db.discountCodes.findIndex((d) => d.id === params.id);
|
|
||||||
if (idx === -1)
|
|
||||||
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
db.discountCodes[idx] = { ...db.discountCodes[idx], ...body };
|
|
||||||
return HttpResponse.json(db.discountCodes[idx]);
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.get(`${API}/referral-codes`, () =>
|
|
||||||
HttpResponse.json({ data: getDb().referralCodes })
|
|
||||||
),
|
|
||||||
|
|
||||||
http.post(`${API}/referral-codes`, async ({ request }) => {
|
|
||||||
const body = (await request.json()) as Partial<
|
|
||||||
import("@/lib/types").ReferralCode
|
|
||||||
> & { generate?: boolean };
|
|
||||||
const db = getDb();
|
|
||||||
const code =
|
|
||||||
body.code && !body.generate
|
|
||||||
? normalizeCode(body.code)
|
|
||||||
: `REF${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
|
||||||
if (db.referralCodes.some((r) => normalizeCode(r.code) === code)) {
|
|
||||||
return HttpResponse.json({ error: "Code exists" }, { status: 409 });
|
|
||||||
}
|
|
||||||
const rc: import("@/lib/types").ReferralCode = {
|
|
||||||
id: generateId("rf"),
|
|
||||||
code,
|
|
||||||
label: body.label ?? "Campaign",
|
|
||||||
attributedTo: body.attributedTo,
|
|
||||||
validFrom: body.validFrom ?? format(new Date(), "yyyy-MM-dd"),
|
|
||||||
validTo: body.validTo ?? format(addDays(new Date(), 365), "yyyy-MM-dd"),
|
|
||||||
maxRedemptions: body.maxRedemptions ?? null,
|
|
||||||
redemptionCount: 0,
|
|
||||||
isActive: body.isActive ?? true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
db.referralCodes.push(rc);
|
|
||||||
return HttpResponse.json(rc, { status: 201 });
|
|
||||||
}),
|
|
||||||
|
|
||||||
http.patch(`${API}/referral-codes/:id`, async ({ params, request }) => {
|
|
||||||
const body = (await request.json()) as Partial<
|
|
||||||
import("@/lib/types").ReferralCode
|
|
||||||
>;
|
|
||||||
const db = getDb();
|
|
||||||
const idx = db.referralCodes.findIndex((r) => r.id === params.id);
|
|
||||||
if (idx === -1)
|
|
||||||
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
db.referralCodes[idx] = { ...db.referralCodes[idx], ...body };
|
|
||||||
return HttpResponse.json(db.referralCodes[idx]);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
@ -1,363 +0,0 @@
|
||||||
import { TAX_RATE } from "@/lib/constants";
|
|
||||||
import type {
|
|
||||||
Booking,
|
|
||||||
DiscountCode,
|
|
||||||
Payment,
|
|
||||||
ReferralCode,
|
|
||||||
Room,
|
|
||||||
RoomBlock,
|
|
||||||
SiteVisit,
|
|
||||||
Transaction,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const iso = (d: Date) => d.toISOString();
|
|
||||||
|
|
||||||
function pricing(
|
|
||||||
nightly: number,
|
|
||||||
nights: number,
|
|
||||||
coupon?: string,
|
|
||||||
pct = 0
|
|
||||||
): Booking["pricing"] {
|
|
||||||
const sub = nightly * nights;
|
|
||||||
const discountAmount = sub * (pct / 100);
|
|
||||||
const after = sub - discountAmount;
|
|
||||||
const taxAmount = after * TAX_RATE;
|
|
||||||
const total = after + taxAmount;
|
|
||||||
return {
|
|
||||||
nightlySubtotal: sub,
|
|
||||||
couponCode: coupon,
|
|
||||||
discountPercent: pct,
|
|
||||||
discountAmount,
|
|
||||||
taxRate: TAX_RATE,
|
|
||||||
taxAmount,
|
|
||||||
total,
|
|
||||||
totalCents: Math.round(total * 100),
|
|
||||||
currency: "USD",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const seedBookings: Booking[] = [
|
|
||||||
{
|
|
||||||
id: "b1",
|
|
||||||
guest: {
|
|
||||||
firstName: "Amina",
|
|
||||||
lastName: "Tesfaye",
|
|
||||||
email: "amina@example.com",
|
|
||||||
phone: "+251911000001",
|
|
||||||
flightBookingNumber: "ETH708",
|
|
||||||
arrivalTime: "14:30",
|
|
||||||
},
|
|
||||||
checkIn: "2026-03-22",
|
|
||||||
checkOut: "2026-03-25",
|
|
||||||
guests: 2,
|
|
||||||
roomId: "r-standard-101",
|
|
||||||
nights: 3,
|
|
||||||
pricing: pricing(120, 3, "SHITAYE10", 10),
|
|
||||||
status: "confirmed",
|
|
||||||
holdReference: "SHY-H001",
|
|
||||||
payLaterHold: false,
|
|
||||||
confirmationId: "PAY-001",
|
|
||||||
paidAt: iso(new Date(now.getTime() - 86400000)),
|
|
||||||
referralCode: "PARTNER2024",
|
|
||||||
createdAt: iso(new Date(now.getTime() - 172800000)),
|
|
||||||
updatedAt: iso(now),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b2",
|
|
||||||
guest: {
|
|
||||||
firstName: "James",
|
|
||||||
lastName: "Brown",
|
|
||||||
email: "james@example.com",
|
|
||||||
phone: "+447700900123",
|
|
||||||
flightBookingNumber: "BA123",
|
|
||||||
arrivalTime: "09:00",
|
|
||||||
},
|
|
||||||
checkIn: "2026-03-23",
|
|
||||||
checkOut: "2026-03-28",
|
|
||||||
guests: 4,
|
|
||||||
roomId: "r-suite-201",
|
|
||||||
nights: 5,
|
|
||||||
pricing: pricing(280, 5, undefined, 0),
|
|
||||||
status: "held",
|
|
||||||
holdReference: "SHY-H002",
|
|
||||||
payLaterHold: true,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 3600000)),
|
|
||||||
updatedAt: iso(now),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b3",
|
|
||||||
guest: {
|
|
||||||
firstName: "Sofia",
|
|
||||||
lastName: "Mitchell",
|
|
||||||
email: "sofia@example.com",
|
|
||||||
phone: "+12025550199",
|
|
||||||
flightBookingNumber: "DL44",
|
|
||||||
arrivalTime: "16:00",
|
|
||||||
},
|
|
||||||
checkIn: "2026-03-20",
|
|
||||||
checkOut: "2026-03-22",
|
|
||||||
guests: 2,
|
|
||||||
roomId: "r-studio-305",
|
|
||||||
nights: 2,
|
|
||||||
pricing: pricing(95, 2, "WELCOME5", 5),
|
|
||||||
status: "confirmed",
|
|
||||||
confirmationId: "PAY-003",
|
|
||||||
paidAt: iso(new Date(now.getTime() - 259200000)),
|
|
||||||
payLaterHold: false,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 400000000)),
|
|
||||||
updatedAt: iso(now),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b4",
|
|
||||||
guest: {
|
|
||||||
firstName: "Yonas",
|
|
||||||
lastName: "Kebede",
|
|
||||||
email: "yonas@example.com",
|
|
||||||
phone: "+251922000002",
|
|
||||||
flightBookingNumber: "ET302",
|
|
||||||
arrivalTime: "11:15",
|
|
||||||
},
|
|
||||||
checkIn: "2026-03-25",
|
|
||||||
checkOut: "2026-03-30",
|
|
||||||
guests: 6,
|
|
||||||
roomId: "r-penthouse-1",
|
|
||||||
nights: 5,
|
|
||||||
pricing: pricing(450, 5),
|
|
||||||
status: "payment_pending",
|
|
||||||
holdReference: "SHY-H004",
|
|
||||||
payLaterHold: false,
|
|
||||||
createdAt: iso(now),
|
|
||||||
updatedAt: iso(now),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b5",
|
|
||||||
guest: {
|
|
||||||
firstName: "Elena",
|
|
||||||
lastName: "Rossi",
|
|
||||||
email: "elena@example.it",
|
|
||||||
phone: "+39333111222",
|
|
||||||
flightBookingNumber: "AZ784",
|
|
||||||
arrivalTime: "20:00",
|
|
||||||
},
|
|
||||||
checkIn: "2026-03-18",
|
|
||||||
checkOut: "2026-03-19",
|
|
||||||
guests: 2,
|
|
||||||
roomId: "r-standard-102",
|
|
||||||
nights: 1,
|
|
||||||
pricing: pricing(120, 1),
|
|
||||||
status: "cancelled",
|
|
||||||
holdReference: "SHY-H005",
|
|
||||||
payLaterHold: false,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 500000000)),
|
|
||||||
updatedAt: iso(now),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const seedRooms: Room[] = [
|
|
||||||
{
|
|
||||||
id: "r-standard-101",
|
|
||||||
name: "Room 101",
|
|
||||||
roomTypeSlug: "standard",
|
|
||||||
floor: "1",
|
|
||||||
maxGuests: 2,
|
|
||||||
baseRate: 120,
|
|
||||||
status: "occupied",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r-standard-102",
|
|
||||||
name: "Room 102",
|
|
||||||
roomTypeSlug: "standard",
|
|
||||||
floor: "1",
|
|
||||||
maxGuests: 2,
|
|
||||||
baseRate: 120,
|
|
||||||
status: "available",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r-suite-201",
|
|
||||||
name: "Suite 201",
|
|
||||||
roomTypeSlug: "connecting-suite",
|
|
||||||
floor: "2",
|
|
||||||
maxGuests: 6,
|
|
||||||
baseRate: 280,
|
|
||||||
status: "occupied",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r-studio-305",
|
|
||||||
name: "Studio 305",
|
|
||||||
roomTypeSlug: "junior-studio",
|
|
||||||
floor: "3",
|
|
||||||
maxGuests: 2,
|
|
||||||
baseRate: 95,
|
|
||||||
status: "available",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r-penthouse-1",
|
|
||||||
name: "Penthouse",
|
|
||||||
roomTypeSlug: "penthouse",
|
|
||||||
floor: "PH",
|
|
||||||
maxGuests: 8,
|
|
||||||
baseRate: 450,
|
|
||||||
status: "occupied",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r-standard-103",
|
|
||||||
name: "Room 103",
|
|
||||||
roomTypeSlug: "standard",
|
|
||||||
floor: "1",
|
|
||||||
maxGuests: 2,
|
|
||||||
baseRate: 120,
|
|
||||||
status: "maintenance",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const seedBlocks: RoomBlock[] = [
|
|
||||||
{
|
|
||||||
id: "blk1",
|
|
||||||
roomId: "r-standard-103",
|
|
||||||
startDate: "2026-03-21",
|
|
||||||
endDate: "2026-03-24",
|
|
||||||
reason: "maintenance",
|
|
||||||
title: "HVAC service",
|
|
||||||
createdAt: iso(now),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const seedPayments: Payment[] = [
|
|
||||||
{
|
|
||||||
id: "pay1",
|
|
||||||
bookingId: "b1",
|
|
||||||
provider: "mock",
|
|
||||||
amount: seedBookings[0].pricing.total,
|
|
||||||
currency: "USD",
|
|
||||||
status: "paid",
|
|
||||||
last4: "4242",
|
|
||||||
createdAt: seedBookings[0].paidAt!,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pay3",
|
|
||||||
bookingId: "b3",
|
|
||||||
provider: "mock",
|
|
||||||
amount: seedBookings[2].pricing.total,
|
|
||||||
currency: "USD",
|
|
||||||
status: "paid",
|
|
||||||
last4: "1881",
|
|
||||||
createdAt: seedBookings[2].paidAt!,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const seedTransactions: Transaction[] = [
|
|
||||||
{
|
|
||||||
id: "t1",
|
|
||||||
type: "payment",
|
|
||||||
amount: seedBookings[0].pricing.total,
|
|
||||||
currency: "USD",
|
|
||||||
status: "completed",
|
|
||||||
bookingId: "b1",
|
|
||||||
paymentRef: "PAY-001",
|
|
||||||
description: "Card capture",
|
|
||||||
createdAt: seedBookings[0].paidAt!,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "t2",
|
|
||||||
type: "refund",
|
|
||||||
amount: 80,
|
|
||||||
currency: "USD",
|
|
||||||
status: "completed",
|
|
||||||
bookingId: "b5",
|
|
||||||
description: "Partial refund — cancellation policy",
|
|
||||||
createdAt: iso(new Date(now.getTime() - 86400000)),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const seedDiscountCodes: DiscountCode[] = [
|
|
||||||
{
|
|
||||||
id: "dc1",
|
|
||||||
code: "SHITAYE10",
|
|
||||||
description: "10% off public campaign",
|
|
||||||
discountType: "percent",
|
|
||||||
value: 10,
|
|
||||||
validFrom: "2026-01-01",
|
|
||||||
validTo: "2026-12-31",
|
|
||||||
maxRedemptions: 500,
|
|
||||||
redemptionCount: 42,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 86400000 * 60)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "dc2",
|
|
||||||
code: "WELCOME5",
|
|
||||||
discountType: "percent",
|
|
||||||
value: 5,
|
|
||||||
validFrom: "2026-01-01",
|
|
||||||
validTo: "2026-06-30",
|
|
||||||
maxRedemptions: null,
|
|
||||||
redemptionCount: 18,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 86400000 * 30)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "dc3",
|
|
||||||
code: "OLDPROMO",
|
|
||||||
discountType: "fixed_amount",
|
|
||||||
value: 2500,
|
|
||||||
currency: "USD",
|
|
||||||
validFrom: "2025-01-01",
|
|
||||||
validTo: "2025-12-31",
|
|
||||||
maxRedemptions: 100,
|
|
||||||
redemptionCount: 100,
|
|
||||||
isActive: false,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 86400000 * 400)),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const seedReferralCodes: ReferralCode[] = [
|
|
||||||
{
|
|
||||||
id: "rf1",
|
|
||||||
code: "PARTNER2024",
|
|
||||||
label: "Travel partner Q1",
|
|
||||||
attributedTo: "partner-ethio-tours",
|
|
||||||
validFrom: "2026-01-01",
|
|
||||||
validTo: "2026-12-31",
|
|
||||||
maxRedemptions: null,
|
|
||||||
redemptionCount: 12,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 86400000 * 90)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rf2",
|
|
||||||
code: "VIPGUEST",
|
|
||||||
label: "VIP referrals",
|
|
||||||
validFrom: "2026-03-01",
|
|
||||||
validTo: "2026-12-31",
|
|
||||||
maxRedemptions: 50,
|
|
||||||
redemptionCount: 3,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: iso(new Date(now.getTime() - 86400000 * 5)),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function seedVisits(): SiteVisit[] {
|
|
||||||
const visits: SiteVisit[] = [];
|
|
||||||
let id = 0;
|
|
||||||
for (let d = 29; d >= 0; d--) {
|
|
||||||
const day = new Date(now);
|
|
||||||
day.setDate(day.getDate() - d);
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
visits.push({
|
|
||||||
id: `v${++id}`,
|
|
||||||
occurredAt: new Date(
|
|
||||||
day.getTime() + i * 3600000 + 1000 * id
|
|
||||||
).toISOString(),
|
|
||||||
path: ["/", "/booking", "/rooms", "/contact"][i % 4],
|
|
||||||
referrer: i % 2 ? "https://google.com" : undefined,
|
|
||||||
utmSource: i === 0 ? "newsletter" : undefined,
|
|
||||||
sessionId: `s-${d}-${i}`,
|
|
||||||
device: ["mobile", "desktop"][id % 2],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return visits;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const seedSiteVisits = seedVisits();
|
|
||||||
|
|
@ -7,29 +7,41 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { apiGet, apiPatch } from "@/lib/api";
|
import { apiGet, apiPatch } from "@/lib/api";
|
||||||
|
import { isLikelyApiHotelBooking, mapApiBookingToBooking } from "@/lib/hotel-adapters";
|
||||||
import type { Booking } from "@/lib/types";
|
import type { Booking } from "@/lib/types";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
|
import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
|
||||||
import { roomDisplayName } from "@/lib/room-utils";
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
|
||||||
export function BookingDetailPage() {
|
export function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [b, setB] = useState<Booking | null>(null);
|
const [b, setB] = useState<Booking | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const { canEditBookings } = useAuth();
|
const { canEditBookings } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
apiGet<Booking>(`/bookings/${id}`).then(setB).catch(console.error);
|
apiGet<unknown>(`/bookings/${id}`)
|
||||||
}, [id]);
|
.then((raw) => {
|
||||||
|
if (isLikelyApiHotelBooking(raw)) setB(mapApiBookingToBooking(raw));
|
||||||
|
else setB(raw as Booking);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [id, selectedPropertyId]);
|
||||||
|
|
||||||
if (!b) return <p className="text-muted-foreground">Loading…</p>;
|
if (!b) return <p className="text-muted-foreground">Loading…</p>;
|
||||||
|
|
||||||
async function addNote() {
|
async function addNote() {
|
||||||
if (!b || !note.trim() || !canEditBookings) return;
|
if (!b || !note.trim() || !canEditBookings) return;
|
||||||
const next = await apiPatch<Booking>(`/bookings/${b.id}`, {
|
const next = await apiPatch<unknown>(`/bookings/${b.id}`, {
|
||||||
internalNotes: [note.trim()],
|
internalNotes: [note.trim()],
|
||||||
});
|
});
|
||||||
setB(next);
|
setB(
|
||||||
|
isLikelyApiHotelBooking(next)
|
||||||
|
? mapApiBookingToBooking(next)
|
||||||
|
: (next as Booking)
|
||||||
|
);
|
||||||
setNote("");
|
setNote("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +95,7 @@ export function BookingDetailPage() {
|
||||||
nights)
|
nights)
|
||||||
</p>
|
</p>
|
||||||
<p>Guests: {b.guests}</p>
|
<p>Guests: {b.guests}</p>
|
||||||
<p>Room: {roomDisplayName(b.roomId)}</p>
|
<p>Room: {b.roomDisplayLabel ?? roomDisplayName(b.roomId)}</p>
|
||||||
{b.holdReference && <p>Hold: {b.holdReference}</p>}
|
{b.holdReference && <p>Hold: {b.holdReference}</p>}
|
||||||
{b.confirmationId && <p>Payment ref: {b.confirmationId}</p>}
|
{b.confirmationId && <p>Payment ref: {b.confirmationId}</p>}
|
||||||
{b.paidAt && <p>Paid: {formatDateTime(b.paidAt)}</p>}
|
{b.paidAt && <p>Paid: {formatDateTime(b.paidAt)}</p>}
|
||||||
|
|
|
||||||
|
|
@ -28,30 +28,63 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiDownloadBlob, apiGet } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
bookingStatusToQuery,
|
||||||
|
isLikelyApiHotelBooking,
|
||||||
|
mapApiBookingToBooking,
|
||||||
|
} from "@/lib/hotel-adapters";
|
||||||
import type { Booking } from "@/lib/types";
|
import type { Booking } from "@/lib/types";
|
||||||
import { formatDate, formatMoney } from "@/lib/format";
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { roomDisplayName } from "@/lib/room-utils";
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function BookingsPage() {
|
export function BookingsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const ref = searchParams.get("referral") ?? "";
|
const ref = searchParams.get("referral") ?? "";
|
||||||
const [list, setList] = useState<Booking[]>([]);
|
const [list, setList] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [status, setStatus] = useState<string>("all");
|
const [status, setStatus] = useState<string>("all");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status !== "all") params.set("status", status);
|
const apiStatus = bookingStatusToQuery(status);
|
||||||
|
if (apiStatus) params.set("status", apiStatus);
|
||||||
if (q) params.set("q", q);
|
if (q) params.set("q", q);
|
||||||
if (ref) params.set("referralCode", ref);
|
if (ref) params.set("referralCode", ref);
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
apiGet<{ data: Booking[] }>(`/bookings?${params}`)
|
setLoading(true);
|
||||||
.then((r) => setList(r.data))
|
apiGet<{ data: unknown[] }>(`/bookings?${params}`)
|
||||||
.catch(console.error);
|
.then((r) => {
|
||||||
|
const mapped = r.data.map((row) =>
|
||||||
|
isLikelyApiHotelBooking(row)
|
||||||
|
? mapApiBookingToBooking(row)
|
||||||
|
: (row as Booking)
|
||||||
|
);
|
||||||
|
setList(mapped);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [status, q, ref]);
|
}, [status, q, ref, selectedPropertyId]);
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await apiDownloadBlob("/bookings/export");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename ?? "bookings.csv";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -77,10 +110,13 @@ export function BookingsPage() {
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/bookings/new">+ New booking</Link>
|
<Link to="/bookings/new">+ New booking</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button
|
||||||
<a href="/api/export/bookings.csv" download>
|
type="button"
|
||||||
Export CSV
|
variant="outline"
|
||||||
</a>
|
onClick={() => void exportCsv()}
|
||||||
|
disabled={!selectedPropertyId}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,70 +168,78 @@ export function BookingsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block overflow-x-auto">
|
{loading && list.length === 0 ? (
|
||||||
<Table>
|
<div className="flex min-h-[300px] items-center justify-center">
|
||||||
<TableHeader>
|
<Spinner size={32} />
|
||||||
<TableRow>
|
</div>
|
||||||
<TableHead>Guest</TableHead>
|
) : (
|
||||||
<TableHead>Room</TableHead>
|
<>
|
||||||
<TableHead>Dates</TableHead>
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<TableHead>Status</TableHead>
|
<Table>
|
||||||
<TableHead className="text-right">Total</TableHead>
|
<TableHeader>
|
||||||
<TableHead />
|
<TableRow>
|
||||||
</TableRow>
|
<TableHead>Guest</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Room</TableHead>
|
||||||
<TableBody>
|
<TableHead>Dates</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((b) => (
|
||||||
|
<TableRow key={b.id}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="font-medium">
|
||||||
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{b.guest.email}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{b.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatMoney(b.pricing.total)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={`/bookings/${b.id}`}>Details</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
{list.map((b) => (
|
{list.map((b) => (
|
||||||
<TableRow key={b.id}>
|
<Link
|
||||||
<TableCell>
|
key={b.id}
|
||||||
<p className="font-medium">
|
to={`/bookings/${b.id}`}
|
||||||
{b.guest.firstName} {b.guest.lastName}
|
className="block rounded-xl border p-4"
|
||||||
</p>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="font-medium">
|
||||||
{b.guest.email}
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
<p className="text-xs text-muted-foreground">
|
||||||
<TableCell className="text-sm">
|
{formatDate(b.checkIn)} · {b.status}
|
||||||
{roomDisplayName(b.roomId)}
|
</p>
|
||||||
</TableCell>
|
<p className="mt-2 font-semibold">
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary">{b.status}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">
|
|
||||||
{formatMoney(b.pricing.total)}
|
{formatMoney(b.pricing.total)}
|
||||||
</TableCell>
|
</p>
|
||||||
<TableCell>
|
</Link>
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={`/bookings/${b.id}`}>Details</Link>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-3 md:hidden">
|
|
||||||
{list.map((b) => (
|
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
to={`/bookings/${b.id}`}
|
|
||||||
className="block rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<p className="font-medium">
|
|
||||||
{b.guest.firstName} {b.guest.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(b.checkIn)} · {b.status}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 font-semibold">
|
|
||||||
{formatMoney(b.pricing.total)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function CalendarPage() {
|
||||||
const [start, setStart] = useState("");
|
const [start, setStart] = useState("");
|
||||||
const [end, setEnd] = useState("");
|
const [end, setEnd] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [reason, setReason] = useState<RoomBlock["reason"]>("maintenance");
|
const [reason, setReason] = useState<RoomBlock["reason"]>("MAINTENANCE");
|
||||||
|
|
||||||
const loadRooms = useCallback(() => {
|
const loadRooms = useCallback(() => {
|
||||||
apiGet<{ data: Room[] }>("/rooms").then((r) => {
|
apiGet<{ data: Room[] }>("/rooms").then((r) => {
|
||||||
|
|
@ -138,10 +138,10 @@ export function CalendarPage() {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="maintenance">Maintenance</SelectItem>
|
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
|
||||||
<SelectItem value="owner_hold">Owner hold</SelectItem>
|
<SelectItem value="OWNER_BLOCK">Owner hold</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
{/* <SelectItem value="closed">Closed</SelectItem> */}
|
||||||
<SelectItem value="other">Other</SelectItem>
|
<SelectItem value="OTHER">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,19 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import type { CustomerRow } from "@/lib/types";
|
import type { CustomerRow } from "@/lib/types";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
|
||||||
export function CustomersPage() {
|
export function CustomersPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rows, setRows] = useState<CustomerRow[]>([]);
|
const [rows, setRows] = useState<CustomerRow[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<{ data: CustomerRow[] }>("/customers").then((r) =>
|
apiGet<{ data: CustomerRow[] }>("/customers").then((r) =>
|
||||||
setRows(r.data)
|
setRows(r.data)
|
||||||
);
|
);
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,11 @@ import {
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import type { Booking, DashboardPayload } from "@/lib/types";
|
|
||||||
import { formatDate, formatMoney } from "@/lib/format";
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { roomDisplayName } from "@/lib/room-utils";
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import type { Booking, DashboardPayload } from "@/lib/types";
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: "var(--navy)",
|
backgroundColor: "var(--navy)",
|
||||||
|
|
@ -35,18 +37,111 @@ const tooltipStyle = {
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HotelSummaryResponse = {
|
||||||
|
arrivalsToday?: number;
|
||||||
|
arrivals?: number;
|
||||||
|
departuresToday?: number;
|
||||||
|
departures?: number;
|
||||||
|
unpaidHolds?: number;
|
||||||
|
revenueMonth?: string | number;
|
||||||
|
bookingsByStatus?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDashboardPayload(d: unknown): d is DashboardPayload {
|
||||||
|
return typeof d === "object" && d !== null && "bookingSeries" in d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHotelSummary(d: unknown): d is HotelSummaryResponse {
|
||||||
|
if (typeof d !== "object" || d === null) return false;
|
||||||
|
const x = d as HotelSummaryResponse;
|
||||||
|
return (
|
||||||
|
typeof x.arrivalsToday === "number" ||
|
||||||
|
typeof x.arrivals === "number" ||
|
||||||
|
x.bookingsByStatus !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [data, setData] = useState<DashboardPayload | null>(null);
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
const [data, setData] = useState<DashboardPayload | HotelSummaryResponse | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<DashboardPayload>("/dashboard")
|
setErr(null);
|
||||||
|
setData(null);
|
||||||
|
apiGet<DashboardPayload | HotelSummaryResponse>(`/dashboard/summary`)
|
||||||
.then(setData)
|
.then(setData)
|
||||||
.catch((e) => setErr(String(e)));
|
.catch((e) => setErr(String(e)));
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
if (err) return <p className="text-destructive">{err}</p>;
|
if (err) return <p className="text-destructive">{err}</p>;
|
||||||
if (!data) return <p className="text-muted-foreground">Loading…</p>;
|
if (!data) return (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isHotelSummary(data) && !isDashboardPayload(data)) {
|
||||||
|
const arrivals = data.arrivalsToday ?? data.arrivals ?? 0;
|
||||||
|
const departures = data.departuresToday ?? data.departures ?? 0;
|
||||||
|
const revenueRaw = data.revenueMonth ?? 0;
|
||||||
|
const revenueNum =
|
||||||
|
typeof revenueRaw === "string" ? parseFloat(revenueRaw) : Number(revenueRaw);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[
|
||||||
|
{ label: "Arrivals today", value: arrivals },
|
||||||
|
{ label: "Departures today", value: departures },
|
||||||
|
{ label: "Unpaid holds", value: data.unpaidHolds ?? 0 },
|
||||||
|
{
|
||||||
|
label: "Revenue (month)",
|
||||||
|
value: formatMoney(Number.isFinite(revenueNum) ? revenueNum : 0),
|
||||||
|
},
|
||||||
|
].map((c) => (
|
||||||
|
<Card key={c.label} className="rounded-2xl">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{c.label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{c.value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.bookingsByStatus && Object.keys(data.bookingsByStatus).length > 0 ? (
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Bookings by status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(data.bookingsByStatus).map(([k, v]) => (
|
||||||
|
<Badge key={k} variant="secondary">
|
||||||
|
{k}: {v}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDashboardPayload(data)) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unexpected dashboard response. Try again or check the API.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -263,7 +358,7 @@ export function DashboardPage() {
|
||||||
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{roomDisplayName(b.roomId)}
|
{b.roomDisplayLabel ?? roomDisplayName(b.roomId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">{b.status}</Badge>
|
<Badge variant="secondary">{b.status}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { DiscountCode } from "@/lib/types";
|
import type { DiscountCode } from "@/lib/types";
|
||||||
|
|
||||||
function copy(s: string) {
|
function copy(s: string) {
|
||||||
|
|
@ -38,18 +40,21 @@ function copy(s: string) {
|
||||||
|
|
||||||
export function DiscountCodesPage() {
|
export function DiscountCodesPage() {
|
||||||
const { canManageCodes } = useAuth();
|
const { canManageCodes } = useAuth();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rows, setRows] = useState<DiscountCode[]>([]);
|
const [rows, setRows] = useState<DiscountCode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [custom, setCustom] = useState("");
|
const [custom, setCustom] = useState("");
|
||||||
const [generate, setGenerate] = useState(true);
|
|
||||||
const [value, setValue] = useState("10");
|
const [value, setValue] = useState("10");
|
||||||
const [dtype, setDtype] = useState<"percent" | "fixed_amount">("percent");
|
const [dtype, setDtype] = useState<"percent" | "fixed_amount">("percent");
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
apiGet<{ data: DiscountCode[] }>("/discount-codes").then((r) =>
|
apiGet<{ data: DiscountCode[] }>("/discount-codes").then((r) =>
|
||||||
setRows(r.data)
|
setRows(r.data)
|
||||||
);
|
).finally(() => setLoading(false));
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|
@ -57,14 +62,18 @@ export function DiscountCodesPage() {
|
||||||
|
|
||||||
async function create(e: React.FormEvent) {
|
async function create(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await apiPost("/discount-codes", {
|
setSubmitting(true);
|
||||||
generate,
|
try {
|
||||||
code: generate ? undefined : custom,
|
await apiPost("/discount-codes", {
|
||||||
discountType: dtype,
|
code: custom,
|
||||||
value: Number(value),
|
discountType: dtype,
|
||||||
});
|
value: Number(value),
|
||||||
setOpen(false);
|
});
|
||||||
load();
|
setOpen(false);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(dc: DiscountCode) {
|
async function toggle(dc: DiscountCode) {
|
||||||
|
|
@ -87,24 +96,13 @@ export function DiscountCodesPage() {
|
||||||
<DialogTitle>New discount code</DialogTitle>
|
<DialogTitle>New discount code</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={create} className="grid gap-3">
|
<form onSubmit={create} className="grid gap-3">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={generate}
|
|
||||||
onChange={(e) => setGenerate(e.target.checked)}
|
|
||||||
id="gen"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="gen">Auto-generate code</Label>
|
|
||||||
</div>
|
|
||||||
{!generate && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Custom code</Label>
|
<Label>Code</Label>
|
||||||
<Input
|
<Input
|
||||||
value={custom}
|
value={custom}
|
||||||
onChange={(e) => setCustom(e.target.value)}
|
onChange={(e) => setCustom(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -128,7 +126,7 @@ export function DiscountCodesPage() {
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit" loading={submitting}>Create</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -137,7 +135,12 @@ export function DiscountCodesPage() {
|
||||||
|
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-2xl">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Table>
|
{loading && rows.length === 0 ? (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Code</TableHead>
|
<TableHead>Code</TableHead>
|
||||||
|
|
@ -194,8 +197,9 @@ export function DiscountCodesPage() {
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,45 +9,197 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { AdminRole } from "@/lib/types";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
const roles: { id: AdminRole; label: string }[] = [
|
import { normalizeInternationalPhone, useAuthStore } from "@/store/authStore";
|
||||||
{ id: "viewer", label: "Viewer (read-only)" },
|
|
||||||
{ id: "front_desk", label: "Front desk" },
|
|
||||||
{ id: "finance", label: "Finance" },
|
|
||||||
{ id: "superadmin", label: "Super admin" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { setRole } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword);
|
||||||
|
const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp);
|
||||||
|
const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp);
|
||||||
|
|
||||||
function pick(role: AdminRole) {
|
const [email, setEmail] = useState("");
|
||||||
setRole(role);
|
const [password, setPassword] = useState("");
|
||||||
navigate("/dashboard", { replace: true });
|
const [phone, setPhone] = useState("");
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [loginRequestToken, setLoginRequestToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [phoneHint, setPhoneHint] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onEmailLogin(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await loginWithEmailPassword(email, password);
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Sign-in failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRequestOtp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setPhoneHint(null);
|
||||||
|
setLoginRequestToken(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const normalized = normalizeInternationalPhone(phone);
|
||||||
|
const res = await requestPhoneOtp(normalized);
|
||||||
|
setPhoneHint(res.message);
|
||||||
|
if (res.loginRequestToken) setLoginRequestToken(res.loginRequestToken);
|
||||||
|
else
|
||||||
|
setError(
|
||||||
|
"No verification code was issued. Check the number or contact your administrator."
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not send code");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onVerifyOtp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loginRequestToken) {
|
||||||
|
setError("Request a code first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await verifyPhoneOtp(loginRequestToken, otp);
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Invalid code");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
|
||||||
<Card className="w-full max-w-md rounded-2xl shadow-md">
|
<Card className="w-full max-w-md rounded-2xl shadow-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Yaltopia Hotels Admin</CardTitle>
|
<CardTitle className="text-2xl">Yaltopia Hotels</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Mock sign-in — choose a role to explore RBAC (no password).
|
Sign in with the account your administrator created for this property.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2">
|
<CardContent>
|
||||||
{roles.map((r) => (
|
<Tabs defaultValue="email" className="w-full">
|
||||||
<Button
|
<TabsList className="grid w-full grid-cols-2 rounded-xl">
|
||||||
key={r.id}
|
<TabsTrigger value="email" className="rounded-lg">
|
||||||
variant="outline"
|
Email
|
||||||
className="h-12 justify-start rounded-xl"
|
</TabsTrigger>
|
||||||
onClick={() => pick(r.id)}
|
<TabsTrigger value="phone" className="rounded-lg">
|
||||||
>
|
Phone
|
||||||
{r.label}
|
</TabsTrigger>
|
||||||
</Button>
|
</TabsList>
|
||||||
))}
|
|
||||||
|
<TabsContent value="email" className="mt-4 space-y-4">
|
||||||
|
<form onSubmit={onEmailLogin} className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" className="rounded-xl" disabled={loading}>
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="phone" className="mt-4 space-y-4">
|
||||||
|
<form onSubmit={onRequestOtp} className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
required
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+251911234567"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use international format with country code (e.g. +251…).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{phoneHint ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{phoneHint}</p>
|
||||||
|
) : null}
|
||||||
|
{error && !loginRequestToken ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" variant="secondary" className="rounded-xl" disabled={loading}>
|
||||||
|
{loading ? "Sending…" : "Send code"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loginRequestToken ? (
|
||||||
|
<form onSubmit={onVerifyOtp} className="grid gap-4 border-t pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="otp">One-time code</Label>
|
||||||
|
<Input
|
||||||
|
id="otp"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
placeholder="6-digit code"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" className="rounded-xl" disabled={loading}>
|
||||||
|
{loading ? "Verifying…" : "Verify and sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
193
src/pages/ManageUsersPage.tsx
Normal file
193
src/pages/ManageUsersPage.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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 { Trash2 } from "lucide-react";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { HotelStaffRole } from "@/lib/types";
|
||||||
|
import type { RegisterHotelStaffDto, StaffAccess } from "@/lib/types";
|
||||||
|
import { getStaffAccess, postStaff, deleteStaffAccess } from "@/lib/auth-api";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
export function ManageUsersPage() {
|
||||||
|
const token = useAuthStore((s) => s.accessToken);
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
const [staff, setStaff] = useState<StaffAccess[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<RegisterHotelStaffDto>({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
password: "",
|
||||||
|
hotelRole: HotelStaffRole.FRONT_DESK,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStaff = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getStaffAccess(token);
|
||||||
|
setStaff(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load staff", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, selectedPropertyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStaff();
|
||||||
|
}, [loadStaff]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData({ ...formData, [name]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!token) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await postStaff(token, formData);
|
||||||
|
setOpen(false);
|
||||||
|
setFormData({ name: "", email: "", phone: "", password: "", hotelRole: HotelStaffRole.FRONT_DESK });
|
||||||
|
loadStaff();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create staff", error);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!token || !confirm("Are you sure you want to delete this user?")) return;
|
||||||
|
try {
|
||||||
|
await deleteStaffAccess(token, id);
|
||||||
|
loadStaff();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete staff", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) 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">Manage Users</h1>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>+ Add User</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Staff Member</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" name="name" value={formData.name} onChange={handleInputChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" name="email" type="email" value={formData.email} onChange={handleInputChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Phone (optional)</Label>
|
||||||
|
<Input id="phone" name="phone" value={formData.phone} onChange={handleInputChange} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" name="password" type="password" value={formData.password} onChange={handleInputChange} minLength={6} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hotelRole">Role</Label>
|
||||||
|
<Select name="hotelRole" value={formData.hotelRole} onValueChange={(v) => setFormData({ ...formData, hotelRole: v as HotelStaffRole })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={HotelStaffRole.FRONT_DESK}>Front Desk</SelectItem>
|
||||||
|
<SelectItem value={HotelStaffRole.FINANCE}>Finance</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" loading={submitting}>Create User</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Phone</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{staff.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.user.name}</TableCell>
|
||||||
|
<TableCell>{user.user.email}</TableCell>
|
||||||
|
<TableCell>{user.user.phone || "-"}</TableCell>
|
||||||
|
<TableCell>{user.user.role}</TableCell>
|
||||||
|
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(user.id)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{staff.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||||
|
No staff members found. Add one above.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -13,10 +13,18 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
isLikelyApiHotelBooking,
|
||||||
|
isLikelyApiHotelRoom,
|
||||||
|
mapApiBookingToBooking,
|
||||||
|
mapApiRoomToRoom,
|
||||||
|
} from "@/lib/hotel-adapters";
|
||||||
import type { Booking, Room } from "@/lib/types";
|
import type { Booking, Room } from "@/lib/types";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function NewBookingPage() {
|
export function NewBookingPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [roomId, setRoomId] = useState("");
|
const [roomId, setRoomId] = useState("");
|
||||||
const [checkIn, setCheckIn] = useState("");
|
const [checkIn, setCheckIn] = useState("");
|
||||||
|
|
@ -30,21 +38,53 @@ export function NewBookingPage() {
|
||||||
const [arrival, setArrival] = useState("14:00");
|
const [arrival, setArrival] = useState("14:00");
|
||||||
const [coupon, setCoupon] = useState("");
|
const [coupon, setCoupon] = useState("");
|
||||||
const [referral, setReferral] = useState("");
|
const [referral, setReferral] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<{ data: Room[] }>("/rooms")
|
apiGet<{ data: unknown[] }>("/rooms")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setRooms(r.data);
|
const mapped = r.data.map((row) =>
|
||||||
if (r.data[0]) setRoomId(r.data[0].id);
|
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
|
||||||
|
);
|
||||||
|
setRooms(mapped);
|
||||||
|
if (mapped[0]) setRoomId(mapped[0].id);
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
if (selectedPropertyId) {
|
||||||
|
const customer = await apiPost<{ id: string }>("/customers", {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: email || undefined,
|
||||||
|
phone: phone || undefined,
|
||||||
|
});
|
||||||
|
const raw = await apiPost<unknown>("/bookings", {
|
||||||
|
roomId,
|
||||||
|
customerId: customer.id,
|
||||||
|
checkIn,
|
||||||
|
checkOut,
|
||||||
|
guestCount: Number(guests),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
payLaterHold: false,
|
||||||
|
discountCode: coupon.trim() || undefined,
|
||||||
|
referralCode: referral.trim() || undefined,
|
||||||
|
flightPnr: pnr.trim() || undefined,
|
||||||
|
arrivalTime: arrival || undefined,
|
||||||
|
});
|
||||||
|
const created = isLikelyApiHotelBooking(raw)
|
||||||
|
? mapApiBookingToBooking(raw)
|
||||||
|
: (raw as Booking);
|
||||||
|
nav(`/bookings/${created.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body: Partial<Booking> & Record<string, unknown> = {
|
const body: Partial<Booking> & Record<string, unknown> = {
|
||||||
guest: {
|
guest: {
|
||||||
firstName,
|
firstName,
|
||||||
|
|
@ -67,6 +107,8 @@ export function NewBookingPage() {
|
||||||
nav(`/bookings/${created.id}`);
|
nav(`/bookings/${created.id}`);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setErr(e instanceof Error ? e.message : "Failed");
|
setErr(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +238,7 @@ export function NewBookingPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||||
<Button type="submit">Create booking</Button>
|
<Button type="submit" loading={submitting}>Create booking</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,17 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import type { Payment } from "@/lib/types";
|
import type { Payment } from "@/lib/types";
|
||||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
|
|
||||||
export function PaymentsPage() {
|
export function PaymentsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rows, setRows] = useState<Payment[]>([]);
|
const [rows, setRows] = useState<Payment[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<{ data: Payment[] }>("/payments").then((r) => setRows(r.data));
|
apiGet<{ data: Payment[] }>("/payments").then((r) => setRows(r.data));
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||||
import type { ReferralCode } from "@/lib/types";
|
import type { ReferralCode } from "@/lib/types";
|
||||||
|
|
||||||
|
|
@ -32,17 +33,17 @@ function copy(s: string) {
|
||||||
|
|
||||||
export function ReferralCodesPage() {
|
export function ReferralCodesPage() {
|
||||||
const { canManageCodes } = useAuth();
|
const { canManageCodes } = useAuth();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rows, setRows] = useState<ReferralCode[]>([]);
|
const [rows, setRows] = useState<ReferralCode[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [label, setLabel] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [custom, setCustom] = useState("");
|
const [meta, setMeta] = useState("");
|
||||||
const [generate, setGenerate] = useState(true);
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
apiGet<{ data: ReferralCode[] }>("/referral-codes").then((r) =>
|
apiGet<{ data: ReferralCode[] }>("/referral-codes").then((r) =>
|
||||||
setRows(r.data)
|
setRows(r.data)
|
||||||
);
|
);
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|
@ -51,12 +52,9 @@ export function ReferralCodesPage() {
|
||||||
async function create(e: React.FormEvent) {
|
async function create(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await apiPost("/referral-codes", {
|
await apiPost("/referral-codes", {
|
||||||
generate,
|
code,
|
||||||
code: generate ? undefined : custom,
|
|
||||||
label,
|
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setLabel("");
|
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,32 +78,20 @@ export function ReferralCodesPage() {
|
||||||
<DialogTitle>New referral code</DialogTitle>
|
<DialogTitle>New referral code</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={create} className="grid gap-3">
|
<form onSubmit={create} className="grid gap-3">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Campaign label</Label>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={generate}
|
|
||||||
onChange={(e) => setGenerate(e.target.checked)}
|
|
||||||
id="rgen"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="rgen">Auto-generate code</Label>
|
|
||||||
</div>
|
|
||||||
{!generate && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Custom code</Label>
|
<Label>Enter name</Label>
|
||||||
<Input
|
<Input
|
||||||
value={custom}
|
value={meta}
|
||||||
onChange={(e) => setCustom(e.target.value)}
|
onChange={(e) => setMeta(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Enter code</Label>
|
||||||
|
<Input
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit">Create</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -120,7 +106,6 @@ export function ReferralCodesPage() {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Code</TableHead>
|
<TableHead>Code</TableHead>
|
||||||
<TableHead>Label</TableHead>
|
<TableHead>Label</TableHead>
|
||||||
<TableHead>Redemptions</TableHead>
|
|
||||||
<TableHead>Active</TableHead>
|
<TableHead>Active</TableHead>
|
||||||
<TableHead />
|
<TableHead />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -129,11 +114,7 @@ export function ReferralCodesPage() {
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<TableRow key={r.id}>
|
<TableRow key={r.id}>
|
||||||
<TableCell className="font-mono font-medium">{r.code}</TableCell>
|
<TableCell className="font-mono font-medium">{r.code}</TableCell>
|
||||||
<TableCell>{r.label}</TableCell>
|
<TableCell className="font-mono font-medium">{r.meta}</TableCell>
|
||||||
<TableCell>
|
|
||||||
{r.redemptionCount}
|
|
||||||
{r.maxRedemptions != null && ` / ${r.maxRedemptions}`}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={r.isActive ? "success" : "secondary"}>
|
<Badge variant={r.isActive ? "success" : "secondary"}>
|
||||||
{r.isActive ? "Active" : "Off"}
|
{r.isActive ? "Active" : "Off"}
|
||||||
|
|
|
||||||
|
|
@ -4,46 +4,137 @@ import { useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import type { Room } from "@/lib/types";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
interface TimelineResp {
|
type DayStatus = {
|
||||||
days: string[];
|
date: string;
|
||||||
rooms: Room[];
|
status:
|
||||||
segments: {
|
| "VACANT"
|
||||||
bookingId: string;
|
| "RESERVED"
|
||||||
guestName: string;
|
| "CHECK_IN_OUT"
|
||||||
roomId: string;
|
| "BLOCKED"
|
||||||
start: string;
|
| "OCCUPIED";
|
||||||
end: string;
|
clientName?: string;
|
||||||
status: string;
|
bookingId?: string;
|
||||||
paymentLabel: string;
|
blockId?: string;
|
||||||
source: string;
|
blockTitle?: string;
|
||||||
}[];
|
};
|
||||||
|
|
||||||
|
type RoomRow = {
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
roomType: string;
|
||||||
|
days: DayStatus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Segment = {
|
||||||
|
bookingId: string;
|
||||||
|
guestName: string;
|
||||||
|
roomId: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
status: string;
|
||||||
|
paymentLabel: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimelineResp = {
|
||||||
|
month: string; // "2026-03"
|
||||||
|
rooms: RoomRow[];
|
||||||
|
segments?: Segment[]; // or just define it inline
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// convert your API shape into your current UI’s “segments” shape
|
||||||
|
function toSegments(rooms: RoomRow[]): Segment[] {
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
|
||||||
|
for (const r of rooms) {
|
||||||
|
let current: Segment | null = null;
|
||||||
|
|
||||||
|
for (const d of r.days) {
|
||||||
|
const status = d.status;
|
||||||
|
const isOpenDay = status === "VACANT" || status === "CHECK_IN_OUT";
|
||||||
|
|
||||||
|
if (d.clientName && d.bookingId) {
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current.guestName !== d.clientName ||
|
||||||
|
current.bookingId !== d.bookingId
|
||||||
|
) {
|
||||||
|
if (current) {
|
||||||
|
segments.push({ ...current });
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
bookingId: d.bookingId,
|
||||||
|
guestName: d.clientName,
|
||||||
|
roomId: r.roomId,
|
||||||
|
start: d.date,
|
||||||
|
end: d.date,
|
||||||
|
status: "BOOKED",
|
||||||
|
paymentLabel: "Credit",
|
||||||
|
source: "API",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
current.end = d.date;
|
||||||
|
}
|
||||||
|
} else if (d.blockTitle && d.blockId) {
|
||||||
|
segments.push({
|
||||||
|
bookingId: d.blockId,
|
||||||
|
guestName: d.blockTitle,
|
||||||
|
roomId: r.roomId,
|
||||||
|
start: d.date,
|
||||||
|
end: d.date,
|
||||||
|
status: "BLOCKED",
|
||||||
|
paymentLabel: "Maintenance",
|
||||||
|
source: "BLOCK",
|
||||||
|
});
|
||||||
|
} else if (!isOpenDay && current) {
|
||||||
|
segments.push({ ...current });
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
segments.push({ ...current });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function ReservationsPage() {
|
export function ReservationsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
|
const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
|
||||||
const [data, setData] = useState<TimelineResp | null>(null);
|
const [data, setData] = useState<TimelineResp | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<TimelineResp>(`/reservations/timeline?month=${month}`)
|
apiGet<TimelineResp>(`/reservations?month=${month}`)
|
||||||
.then(setData)
|
.then((resp) => {
|
||||||
|
// here resp is the shape you pasted
|
||||||
|
setData(resp);
|
||||||
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [month]);
|
}, [month, selectedPropertyId]);
|
||||||
|
|
||||||
if (!data)
|
if (!data) return <p className="text-muted-foreground">Loading timeline…</p>;
|
||||||
return <p className="text-muted-foreground">Loading timeline…</p>;
|
|
||||||
|
|
||||||
const dayWidth = 56;
|
const dayWidth = 56;
|
||||||
const roomCol = 120;
|
const roomCol = 120;
|
||||||
|
|
||||||
|
// map backend shape → UI segments
|
||||||
|
const segments: Segment[] = toSegments(data.rooms);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Reservations</h1>
|
<h1 className="text-2xl font-bold">Reservations</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Gantt-style view (mock data)
|
Gantt-style view
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -55,9 +146,10 @@ export function ReservationsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge>Occupied</Badge>
|
<Badge variant="default">Occupied</Badge>
|
||||||
<Badge variant="secondary">Check-in / out</Badge>
|
<Badge variant="secondary">Check‑in / out</Badge>
|
||||||
<Badge variant="outline">Reserved</Badge>
|
<Badge variant="outline">Reserved</Badge>
|
||||||
|
<Badge variant="destructive">Blocked</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-2xl">
|
||||||
|
|
@ -68,7 +160,7 @@ export function ReservationsPage() {
|
||||||
<div
|
<div
|
||||||
className="relative min-w-max"
|
className="relative min-w-max"
|
||||||
style={{
|
style={{
|
||||||
width: roomCol + data.days.length * dayWidth,
|
width: roomCol + 31 * dayWidth, // 31 days max
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex border-b">
|
<div className="flex border-b">
|
||||||
|
|
@ -78,44 +170,53 @@ export function ReservationsPage() {
|
||||||
>
|
>
|
||||||
Room
|
Room
|
||||||
</div>
|
</div>
|
||||||
{data.days.map((d) => (
|
{Array.from({ length: 31 }, (_, i) => {
|
||||||
<div
|
const d = i + 1;
|
||||||
key={d}
|
return (
|
||||||
className="shrink-0 border-r p-1 text-center text-[10px] text-muted-foreground"
|
<div
|
||||||
style={{ width: dayWidth }}
|
key={d}
|
||||||
>
|
className="shrink-0 border-r p-1 text-center text-[10px] text-muted-foreground"
|
||||||
{d.slice(8)}
|
style={{ width: dayWidth }}
|
||||||
</div>
|
>
|
||||||
))}
|
{d.toString()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.rooms.map((room) => (
|
{data.rooms.map((room) => (
|
||||||
<div key={room.id} className="flex border-b">
|
<div key={room.roomId} className="flex border-b">
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-r p-2 text-xs font-medium"
|
className="shrink-0 border-r p-2 text-xs font-medium"
|
||||||
style={{ width: roomCol }}
|
style={{ width: roomCol }}
|
||||||
>
|
>
|
||||||
{room.name}
|
{room.roomName}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="relative shrink-0"
|
className="relative shrink-0"
|
||||||
style={{ width: data.days.length * dayWidth, height: 48 }}
|
style={{ width: 31 * dayWidth, height: 48 }}
|
||||||
>
|
>
|
||||||
{data.segments
|
{segments
|
||||||
.filter((s) => s.roomId === room.id)
|
.filter((s) => s.roomId === room.roomId)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const startIdx = data.days.findIndex(
|
const startIdx =
|
||||||
(d) => d >= s.start
|
parseInt(s.start.slice(8), 10) - 1;
|
||||||
);
|
const endIdx =
|
||||||
const endIdx = data.days.findIndex((d) => d >= s.end);
|
parseInt(s.end.slice(8), 10) - 1;
|
||||||
const si =
|
|
||||||
startIdx >= 0 ? startIdx : 0;
|
const si = Math.max(0, startIdx);
|
||||||
const ei =
|
const ei = Math.min(31, endIdx + 1);
|
||||||
endIdx >= 0 ? endIdx : data.days.length;
|
|
||||||
const span = Math.max(1, ei - si);
|
const span = Math.max(1, ei - si);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={s.bookingId}
|
key={s.bookingId}
|
||||||
className="absolute top-2 flex h-8 items-center rounded-lg border bg-accent px-2 text-[10px] shadow-sm"
|
className={`absolute top-2 flex h-8 items-center rounded-lg border px-2 text-[10px] shadow-sm ${
|
||||||
|
s.status === "BLOCKED"
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-accent"
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
left: si * dayWidth + 4,
|
left: si * dayWidth + 4,
|
||||||
width: span * dayWidth - 8,
|
width: span * dayWidth - 8,
|
||||||
|
|
@ -137,3 +238,4 @@ export function ReservationsPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,21 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
isLikelyApiHotelRoom,
|
||||||
|
mapApiRoomToRoom,
|
||||||
|
} from "@/lib/hotel-adapters";
|
||||||
import { ROOM_CATALOGUE } from "@/lib/constants";
|
import { ROOM_CATALOGUE } from "@/lib/constants";
|
||||||
import type { Room } from "@/lib/types";
|
import type { Room } from "@/lib/types";
|
||||||
import { formatMoney } from "@/lib/format";
|
import { formatMoney } from "@/lib/format";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function RoomsPage() {
|
export function RoomsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
|
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
|
||||||
|
|
@ -41,28 +50,43 @@ export function RoomsPage() {
|
||||||
const [baseRate, setBaseRate] = useState("120");
|
const [baseRate, setBaseRate] = useState("120");
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
apiGet<{ data: Room[] }>("/rooms").then((r) => setRooms(r.data));
|
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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
async function addRoom(e: React.FormEvent) {
|
async function addRoom(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await apiPost<Room>("/rooms", {
|
setSubmitting(true);
|
||||||
name,
|
try {
|
||||||
roomTypeSlug: slug,
|
await apiPost<Room>("/rooms", {
|
||||||
maxGuests: Number(maxGuests),
|
name,
|
||||||
baseRate: Number(baseRate),
|
roomType: slug,
|
||||||
status: "available",
|
maxGuests: Number(maxGuests),
|
||||||
floor: "",
|
baseRate: Number(baseRate),
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setName("");
|
setName("");
|
||||||
load();
|
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">
|
||||||
|
|
@ -118,7 +142,7 @@ export function RoomsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit" loading={submitting}>Save</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,54 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { ChevronRight, Users } from "lucide-react";
|
||||||
import { TAX_RATE } from "@/lib/constants";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: "Manage Users",
|
||||||
|
path: "/settings/users",
|
||||||
|
icon: <Users className="size-4" />,
|
||||||
|
description: "View and manage hotel staff members"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<section className="bg-slate-50 min-h-screen p-6">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<div className="max-w-3xl mx-auto">
|
||||||
<Card className="rounded-2xl">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Property (mock)</CardTitle>
|
<div className="py-3" />
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
<div>
|
||||||
<p>Tax rate used in MSW pricing: {(TAX_RATE * 100).toFixed(0)}%</p>
|
<div className="bg-white rounded-2xl border border-blue-200 shadow-sm overflow-hidden">
|
||||||
<p>Connect real backend, auth, and PSP in a future phase.</p>
|
{menuItems.map((item) => (
|
||||||
</CardContent>
|
<Link
|
||||||
</Card>
|
to={item.path}
|
||||||
</div>
|
key={item.label}
|
||||||
|
className="flex items-center justify-between px-6 py-6 border-b border-blue-100 last:border-b-0 hover:bg-blue-50 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl p-3 shadow-md">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-gray-800 block leading-tight">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 block mt-1">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl p-2 shadow-md flex-shrink-0">
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,19 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
|
|
||||||
export function TransactionsPage() {
|
export function TransactionsPage() {
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
const [rows, setRows] = useState<Transaction[]>([]);
|
const [rows, setRows] = useState<Transaction[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<{ data: Transaction[] }>("/transactions").then((r) =>
|
apiGet<{ data: Transaction[] }>("/transactions").then((r) =>
|
||||||
setRows(r.data)
|
setRows(r.data)
|
||||||
);
|
);
|
||||||
}, []);
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,13 @@ export function VisitsPage() {
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const v = await apiGet<{
|
const v = await apiGet<{
|
||||||
series: { date: string; views: number; sessions: number }[];
|
series: Array<{ date: string; count: number }>;
|
||||||
}>("/analytics/visits");
|
}>("/analytics/visits");
|
||||||
setSeries(v.series.slice(-21));
|
setSeries(v.series.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
views: item.count || 0,
|
||||||
|
sessions: 0
|
||||||
|
})).slice(-21));
|
||||||
const r = await apiGet<{ data: SiteVisit[] }>("/analytics/visits/recent");
|
const r = await apiGet<{ data: SiteVisit[] }>("/analytics/visits/recent");
|
||||||
setRecent(r.data);
|
setRecent(r.data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -45,7 +49,7 @@ export function VisitsPage() {
|
||||||
async function simulateHit() {
|
async function simulateHit() {
|
||||||
await apiPost("/analytics/visits", {
|
await apiPost("/analytics/visits", {
|
||||||
path: "/booking",
|
path: "/booking",
|
||||||
device: "desktop",
|
// device: "desktop",
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
src/store/authStore.ts
Normal file
209
src/store/authStore.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getProperties,
|
||||||
|
getProfile,
|
||||||
|
postLogin,
|
||||||
|
postLoginPhoneRequest,
|
||||||
|
postLoginPhoneVerify,
|
||||||
|
registerHotelAuthApiContext,
|
||||||
|
type AuthUser,
|
||||||
|
type PropertyRow,
|
||||||
|
} from "@/lib/auth-api";
|
||||||
|
import { registerHotelApiContext } from "@/lib/api";
|
||||||
|
import type { AdminRole } from "@/lib/types";
|
||||||
|
|
||||||
|
/** Normalize to E.164-style `+` + digits so it matches DB values stored as full international (e.g. +251…). */
|
||||||
|
export function normalizeInternationalPhone(raw: string): string {
|
||||||
|
const trimmed = raw.trim().replace(/\s+/g, "");
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
const digits = trimmed.replace(/\D/g, "");
|
||||||
|
if (!digits) return trimmed;
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBackendRoleToAdminRole(role: string): AdminRole {
|
||||||
|
switch (role) {
|
||||||
|
case "HOTEL_VIEWER":
|
||||||
|
return "viewer";
|
||||||
|
case "HOTEL_FRONT_DESK":
|
||||||
|
return "front_desk";
|
||||||
|
case "HOTEL_FINANCE":
|
||||||
|
return "finance";
|
||||||
|
case "ADMIN":
|
||||||
|
case "SUPER_ADMIN":
|
||||||
|
return "ADMIN";
|
||||||
|
case "PROJECT_MANAGER":
|
||||||
|
return "viewer";
|
||||||
|
default:
|
||||||
|
return "viewer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hotelEligibleProperties(rows: PropertyRow[]): PropertyRow[] {
|
||||||
|
return rows.filter(
|
||||||
|
(p) => p.accommodationMode === "HOTEL" || p.accommodationMode === "MIXED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
accessToken: string | null;
|
||||||
|
user: AuthUser | null;
|
||||||
|
adminRole: AdminRole | null;
|
||||||
|
properties: PropertyRow[];
|
||||||
|
selectedPropertyId: string | null;
|
||||||
|
bootstrapped: boolean;
|
||||||
|
bootstrapError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthActions = {
|
||||||
|
setSession: (access_token: string, user: AuthUser) => Promise<void>;
|
||||||
|
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||||
|
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
|
||||||
|
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
|
||||||
|
bootstrap: () => Promise<void>;
|
||||||
|
setSelectedPropertyId: (id: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState & AuthActions>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
accessToken: null,
|
||||||
|
user: null,
|
||||||
|
adminRole: null,
|
||||||
|
properties: [],
|
||||||
|
selectedPropertyId: null,
|
||||||
|
bootstrapped: false,
|
||||||
|
bootstrapError: null,
|
||||||
|
|
||||||
|
setSession: async (access_token, user) => {
|
||||||
|
const adminRole = mapBackendRoleToAdminRole(user.role);
|
||||||
|
set({
|
||||||
|
accessToken: access_token,
|
||||||
|
user,
|
||||||
|
adminRole,
|
||||||
|
bootstrapError: null,
|
||||||
|
});
|
||||||
|
const props = await getProperties(access_token);
|
||||||
|
const hotelProps = hotelEligibleProperties(props);
|
||||||
|
set({ properties: hotelProps });
|
||||||
|
const cur = get().selectedPropertyId;
|
||||||
|
const nextId =
|
||||||
|
user.propertyId ||
|
||||||
|
(cur && hotelProps.some((p) => p.id === cur)
|
||||||
|
? cur
|
||||||
|
: hotelProps[0]?.id ?? null);
|
||||||
|
set({ selectedPropertyId: nextId, bootstrapped: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
loginWithEmailPassword: async (email, password) => {
|
||||||
|
const { access_token, user } = await postLogin(email.trim(), password);
|
||||||
|
await get().setSession(access_token, user);
|
||||||
|
},
|
||||||
|
|
||||||
|
requestPhoneOtp: async (phone) => {
|
||||||
|
const normalized = normalizeInternationalPhone(phone);
|
||||||
|
return postLoginPhoneRequest(normalized);
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyPhoneOtp: async (loginRequestToken, otp) => {
|
||||||
|
const { access_token, user } = await postLoginPhoneVerify(
|
||||||
|
loginRequestToken,
|
||||||
|
otp.trim()
|
||||||
|
);
|
||||||
|
await get().setSession(access_token, user);
|
||||||
|
},
|
||||||
|
|
||||||
|
bootstrap: async () => {
|
||||||
|
const token = get().accessToken;
|
||||||
|
if (!token) {
|
||||||
|
set({ bootstrapped: true, properties: [], selectedPropertyId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profile = await getProfile(token);
|
||||||
|
const adminRole = mapBackendRoleToAdminRole(profile.role);
|
||||||
|
const props = await getProperties(token);
|
||||||
|
const hotelProps = hotelEligibleProperties(props);
|
||||||
|
const cur = get().selectedPropertyId;
|
||||||
|
const nextId =
|
||||||
|
profile.propertyId ||
|
||||||
|
(cur && hotelProps.some((p) => p.id === cur)
|
||||||
|
? cur
|
||||||
|
: hotelProps[0]?.id ?? null);
|
||||||
|
set({
|
||||||
|
user: {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
role: profile.role,
|
||||||
|
status: profile.status,
|
||||||
|
propertyId: profile.propertyId as string | undefined,
|
||||||
|
},
|
||||||
|
adminRole,
|
||||||
|
properties: hotelProps,
|
||||||
|
selectedPropertyId: nextId,
|
||||||
|
bootstrapError: null,
|
||||||
|
bootstrapped: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
user: null,
|
||||||
|
adminRole: null,
|
||||||
|
properties: [],
|
||||||
|
selectedPropertyId: null,
|
||||||
|
bootstrapError: "Session expired. Please sign in again.",
|
||||||
|
bootstrapped: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedPropertyId: (id) => set({ selectedPropertyId: id }),
|
||||||
|
|
||||||
|
logout: () =>
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
user: null,
|
||||||
|
adminRole: null,
|
||||||
|
properties: [],
|
||||||
|
selectedPropertyId: null,
|
||||||
|
bootstrapError: null,
|
||||||
|
bootstrapped: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "yaltopia-hotels-auth",
|
||||||
|
partialize: (s) => ({
|
||||||
|
accessToken: s.accessToken,
|
||||||
|
selectedPropertyId: s.selectedPropertyId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
registerHotelApiContext({
|
||||||
|
getToken: () => useAuthStore.getState().accessToken,
|
||||||
|
getPropertyId: () => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (!state.bootstrapped && state.accessToken) return null;
|
||||||
|
if (state.adminRole === "ADMIN") {
|
||||||
|
return state.selectedPropertyId;
|
||||||
|
}
|
||||||
|
return state.user?.propertyId || state.selectedPropertyId || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHotelAuthApiContext({
|
||||||
|
getPropertyId: () => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (!state.bootstrapped && state.accessToken) return null;
|
||||||
|
if (state.adminRole === "ADMIN") {
|
||||||
|
return state.selectedPropertyId;
|
||||||
|
}
|
||||||
|
return state.user?.propertyId || state.selectedPropertyId || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
10
src/vite-env.d.ts
vendored
10
src/vite-env.d.ts
vendored
|
|
@ -1 +1,11 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
readonly VITE_MSW?: string;
|
||||||
|
readonly VITE_PROXY_TARGET?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.VITE_PROXY_TARGET ?? "http://localhost:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user