Yaltopia-Hotels/src/pages/LoyaltyPointsPage.tsx

261 lines
8.9 KiB
TypeScript

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>
);
}