loyality page and sidebars
This commit is contained in:
parent
984efd5906
commit
b41d761904
|
|
@ -18,6 +18,9 @@ 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";
|
import { ManageUsersPage } from "@/pages/ManageUsersPage";
|
||||||
|
import { GuestServicesPage } from "@/pages/GuestServicesPage";
|
||||||
|
import { LoyaltyPointsPage } from "@/pages/LoyaltyPointsPage";
|
||||||
|
import { HotelRafflesPage } from "@/pages/HotelRafflesPage";
|
||||||
|
|
||||||
function ProtectedLayout() {
|
function ProtectedLayout() {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
|
@ -46,6 +49,9 @@ export default function App() {
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/rooms" element={<RoomsPage />} />
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
|
<Route path="/guest-services" element={<GuestServicesPage />} />
|
||||||
|
<Route path="/loyalty/points" element={<LoyaltyPointsPage />} />
|
||||||
|
<Route path="/loyalty/raffles" element={<HotelRafflesPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/transactions" element={<TransactionsPage />} />
|
<Route path="/transactions" element={<TransactionsPage />} />
|
||||||
<Route path="/payments" element={<PaymentsPage />} />
|
<Route path="/payments" element={<PaymentsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
navFinance,
|
navFinance,
|
||||||
|
navGuestLoyalty,
|
||||||
navMain,
|
navMain,
|
||||||
navMarketing,
|
navMarketing,
|
||||||
navOther,
|
navOther,
|
||||||
|
|
@ -59,6 +60,7 @@ export function AppSidebar() {
|
||||||
<ScrollArea className="flex-1 px-3 py-4">
|
<ScrollArea className="flex-1 px-3 py-4">
|
||||||
<nav className="space-y-6">
|
<nav className="space-y-6">
|
||||||
<NavSection title="Main" items={navMain} />
|
<NavSection title="Main" items={navMain} />
|
||||||
|
<NavSection title="Guest & loyalty" items={navGuestLoyalty} />
|
||||||
<NavSection title="Marketing" items={navMarketing} />
|
<NavSection title="Marketing" items={navMarketing} />
|
||||||
<NavSection title="Finance" items={navFinance} />
|
<NavSection title="Finance" items={navFinance} />
|
||||||
<NavSection title="Other" items={navOther} />
|
<NavSection title="Other" items={navOther} />
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import {
|
||||||
BedDouble,
|
BedDouble,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Gift,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Percent,
|
Percent,
|
||||||
Settings,
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
UtensilsCrossed,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const navMain = [
|
export const navMain = [
|
||||||
|
|
@ -19,6 +22,12 @@ export const navMain = [
|
||||||
{ to: "/rooms", label: "Rooms", icon: Building2 },
|
{ to: "/rooms", label: "Rooms", icon: Building2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const navGuestLoyalty = [
|
||||||
|
{ to: "/guest-services", label: "Guest services", icon: UtensilsCrossed },
|
||||||
|
{ to: "/loyalty/points", label: "Point rules", icon: Gift },
|
||||||
|
{ to: "/loyalty/raffles", label: "Raffles", icon: Trophy },
|
||||||
|
];
|
||||||
|
|
||||||
export const navMarketing = [
|
export const navMarketing = [
|
||||||
{ to: "/marketing/visits", label: "Site visits", icon: LineChart },
|
{ to: "/marketing/visits", label: "Site visits", icon: LineChart },
|
||||||
{ to: "/marketing/discount-codes", label: "Discount codes", icon: Percent },
|
{ to: "/marketing/discount-codes", label: "Discount codes", icon: Percent },
|
||||||
|
|
|
||||||
61
src/lib/hotel-staff-types.ts
Normal file
61
src/lib/hotel-staff-types.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/** Backend-aligned types for hotel staff UI */
|
||||||
|
|
||||||
|
export type HotelPointAction = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotelPointRule = {
|
||||||
|
id: string;
|
||||||
|
propertyId: string;
|
||||||
|
actionId: string;
|
||||||
|
points: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
action: HotelPointAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotelRaffleRow = {
|
||||||
|
id: string;
|
||||||
|
propertyId: string;
|
||||||
|
prizeDescription: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
drawMode: "RANDOM" | "MIN_POINTS";
|
||||||
|
minPoints: number | null;
|
||||||
|
pointsDeductOnWin: number;
|
||||||
|
maxWinners: number;
|
||||||
|
/** When true, winners are emailed after the draw (persisted as `notifyOnCreate` in API responses). */
|
||||||
|
notifyOnCreate: boolean;
|
||||||
|
status: string;
|
||||||
|
_count?: { entries: number; winners: number };
|
||||||
|
eligiblePoolCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotelLedgerRow = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
propertyId: string;
|
||||||
|
delta: number;
|
||||||
|
reason: string;
|
||||||
|
createdAt: string;
|
||||||
|
sourceKey?: string | null;
|
||||||
|
user?: { id: string; name: string; email?: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BookingTrendDay = {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
cancelled: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RatingSnapshot = {
|
||||||
|
averageRating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
discountUses: number;
|
||||||
|
referralBookings: number;
|
||||||
|
};
|
||||||
260
src/pages/LoyaltyPointsPage.tsx
Normal file
260
src/pages/LoyaltyPointsPage.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type { HotelLedgerRow, HotelPointAction, HotelPointRule } from "@/lib/hotel-staff-types";
|
||||||
|
|
||||||
|
type RowEdit = Record<string, { points: string; isEnabled: boolean }>;
|
||||||
|
|
||||||
|
export function LoyaltyPointsPage() {
|
||||||
|
const { canManageLoyalty, canViewFinanceLedger } = useAuth();
|
||||||
|
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||||
|
const [catalog, setCatalog] = useState<HotelPointAction[]>([]);
|
||||||
|
const [rules, setRules] = useState<HotelPointRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [edit, setEdit] = useState<RowEdit>({});
|
||||||
|
const [ledger, setLedger] = useState<HotelLedgerRow[]>([]);
|
||||||
|
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||||
|
const [ledgerError, setLedgerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const ruleByActionId = useMemo(() => {
|
||||||
|
const m = new Map<string, HotelPointRule>();
|
||||||
|
for (const r of rules) m.set(r.actionId, r);
|
||||||
|
return m;
|
||||||
|
}, [rules]);
|
||||||
|
|
||||||
|
const loadLedger = useCallback(async () => {
|
||||||
|
if (!canViewFinanceLedger) return;
|
||||||
|
setLedgerLoading(true);
|
||||||
|
setLedgerError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiGet<{ data: HotelLedgerRow[] }>("/loyalty/ledger");
|
||||||
|
setLedger(res.data ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setLedger([]);
|
||||||
|
setLedgerError(e instanceof Error ? e.message : "Could not load ledger");
|
||||||
|
} finally {
|
||||||
|
setLedgerLoading(false);
|
||||||
|
}
|
||||||
|
}, [canViewFinanceLedger, selectedPropertyId]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [c, r] = await Promise.all([
|
||||||
|
apiGet<HotelPointAction[]>("/loyalty/catalog"),
|
||||||
|
apiGet<HotelPointRule[]>("/loyalty/rules"),
|
||||||
|
]);
|
||||||
|
setCatalog(c.filter((a) => a.isActive));
|
||||||
|
setRules(r);
|
||||||
|
const next: RowEdit = {};
|
||||||
|
for (const a of c) {
|
||||||
|
const existing = r.find((x) => x.actionId === a.id);
|
||||||
|
next[a.id] = {
|
||||||
|
points: String(existing?.points ?? 0),
|
||||||
|
isEnabled: existing?.isEnabled ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setEdit(next);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedPropertyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLedger();
|
||||||
|
}, [loadLedger]);
|
||||||
|
|
||||||
|
async function saveRow(actionId: string) {
|
||||||
|
if (!canManageLoyalty) return;
|
||||||
|
const row = edit[actionId];
|
||||||
|
if (!row) return;
|
||||||
|
const points = Math.max(0, parseInt(row.points, 10) || 0);
|
||||||
|
setSavingId(actionId);
|
||||||
|
try {
|
||||||
|
await apiPost("/loyalty/rules", {
|
||||||
|
actionId,
|
||||||
|
points,
|
||||||
|
isEnabled: row.isEnabled,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[320px] items-center justify-center">
|
||||||
|
<Spinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Point rules</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Set how many points guests earn per action
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead className="w-28">Points</TableHead>
|
||||||
|
<TableHead className="w-32">Enabled</TableHead>
|
||||||
|
<TableHead className="w-28" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{catalog.map((a) => {
|
||||||
|
const e = edit[a.id] ?? { points: "0", isEnabled: true };
|
||||||
|
const hasRule = ruleByActionId.has(a.id);
|
||||||
|
return (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{a.code}</TableCell>
|
||||||
|
<TableCell>{a.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9 w-24"
|
||||||
|
disabled={!canManageLoyalty}
|
||||||
|
value={e.points}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setEdit((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[a.id]: { ...e, points: ev.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!canManageLoyalty}
|
||||||
|
checked={e.isEnabled}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setEdit((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[a.id]: { ...e, isEnabled: ev.target.checked },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{hasRule ? "On" : "New"}
|
||||||
|
</label>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canManageLoyalty && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={savingId === a.id}
|
||||||
|
onClick={() => void saveRow(a.id)}
|
||||||
|
>
|
||||||
|
{savingId === a.id ? "…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{!catalog.length && (
|
||||||
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
No point actions in catalog
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">Finance: point ledger</CardTitle>
|
||||||
|
{canViewFinanceLedger ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={ledgerLoading}
|
||||||
|
onClick={() => void loadLedger()}
|
||||||
|
>
|
||||||
|
{ledgerLoading ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm">
|
||||||
|
{!canViewFinanceLedger ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Full property ledger is visible to finance and admin roles only.
|
||||||
|
</p>
|
||||||
|
) : ledgerError ? (
|
||||||
|
<p className="text-destructive">{ledgerError}</p>
|
||||||
|
) : ledgerLoading && !ledger.length ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner size={28} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead className="text-right">Δ</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ledger.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{new Date(row.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
<div>{row.user?.name ?? row.userId}</div>
|
||||||
|
<div className="text-muted-foreground">{row.user?.email ?? ""}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-xs">{row.delta}</TableCell>
|
||||||
|
<TableCell className="text-xs">{row.reason}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
{canViewFinanceLedger && !ledgerLoading && !ledger.length && !ledgerError ? (
|
||||||
|
<p className="py-6 text-center text-muted-foreground text-sm">No ledger rows yet.</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user