discount, referrals and customers page
This commit is contained in:
parent
4ed7161a33
commit
0f9d0b7e6f
|
|
@ -10,17 +10,19 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import type { CustomerRow } from "@/lib/types";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export function CustomersPage() {
|
||||
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||
const [rows, setRows] = useState<CustomerRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ data: CustomerRow[] }>("/customers").then((r) =>
|
||||
setRows(r.data)
|
||||
);
|
||||
}, []);
|
||||
}, [selectedPropertyId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { DiscountCode } from "@/lib/types";
|
||||
|
||||
function copy(s: string) {
|
||||
|
|
@ -38,18 +40,21 @@ function copy(s: string) {
|
|||
|
||||
export function DiscountCodesPage() {
|
||||
const { canManageCodes } = useAuth();
|
||||
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||
const [rows, setRows] = useState<DiscountCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [custom, setCustom] = useState("");
|
||||
const [generate, setGenerate] = useState(true);
|
||||
const [value, setValue] = useState("10");
|
||||
const [dtype, setDtype] = useState<"percent" | "fixed_amount">("percent");
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
apiGet<{ data: DiscountCode[] }>("/discount-codes").then((r) =>
|
||||
setRows(r.data)
|
||||
);
|
||||
}, []);
|
||||
).finally(() => setLoading(false));
|
||||
}, [selectedPropertyId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
|
@ -57,14 +62,18 @@ export function DiscountCodesPage() {
|
|||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await apiPost("/discount-codes", {
|
||||
generate,
|
||||
code: generate ? undefined : custom,
|
||||
discountType: dtype,
|
||||
value: Number(value),
|
||||
});
|
||||
setOpen(false);
|
||||
load();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiPost("/discount-codes", {
|
||||
code: custom,
|
||||
discountType: dtype,
|
||||
value: Number(value),
|
||||
});
|
||||
setOpen(false);
|
||||
load();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(dc: DiscountCode) {
|
||||
|
|
@ -87,24 +96,13 @@ export function DiscountCodesPage() {
|
|||
<DialogTitle>New discount code</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<Label>Custom code</Label>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
|
|
@ -128,7 +126,7 @@ export function DiscountCodesPage() {
|
|||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Create</Button>
|
||||
<Button type="submit" loading={submitting}>Create</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -137,7 +135,12 @@ export function DiscountCodesPage() {
|
|||
|
||||
<Card className="rounded-2xl">
|
||||
<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>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
|
|
@ -194,8 +197,9 @@ export function DiscountCodesPage() {
|
|||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,18 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import {
|
||||
isLikelyApiHotelBooking,
|
||||
isLikelyApiHotelRoom,
|
||||
mapApiBookingToBooking,
|
||||
mapApiRoomToRoom,
|
||||
} from "@/lib/hotel-adapters";
|
||||
import type { Booking, Room } from "@/lib/types";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
export function NewBookingPage() {
|
||||
const nav = useNavigate();
|
||||
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [roomId, setRoomId] = useState("");
|
||||
const [checkIn, setCheckIn] = useState("");
|
||||
|
|
@ -30,21 +38,53 @@ export function NewBookingPage() {
|
|||
const [arrival, setArrival] = useState("14:00");
|
||||
const [coupon, setCoupon] = useState("");
|
||||
const [referral, setReferral] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ data: Room[] }>("/rooms")
|
||||
apiGet<{ data: unknown[] }>("/rooms")
|
||||
.then((r) => {
|
||||
setRooms(r.data);
|
||||
if (r.data[0]) setRoomId(r.data[0].id);
|
||||
const mapped = r.data.map((row) =>
|
||||
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
|
||||
);
|
||||
setRooms(mapped);
|
||||
if (mapped[0]) setRoomId(mapped[0].id);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
}, [selectedPropertyId]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setSubmitting(true);
|
||||
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> = {
|
||||
guest: {
|
||||
firstName,
|
||||
|
|
@ -67,6 +107,8 @@ export function NewBookingPage() {
|
|||
nav(`/bookings/${created.id}`);
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +238,7 @@ export function NewBookingPage() {
|
|||
</Card>
|
||||
|
||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||
<Button type="submit">Create booking</Button>
|
||||
<Button type="submit" loading={submitting}>Create booking</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||
import type { ReferralCode } from "@/lib/types";
|
||||
|
||||
|
|
@ -32,17 +33,17 @@ function copy(s: string) {
|
|||
|
||||
export function ReferralCodesPage() {
|
||||
const { canManageCodes } = useAuth();
|
||||
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||
const [rows, setRows] = useState<ReferralCode[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [custom, setCustom] = useState("");
|
||||
const [generate, setGenerate] = useState(true);
|
||||
const [code, setCode] = useState("");
|
||||
const [meta, setMeta] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiGet<{ data: ReferralCode[] }>("/referral-codes").then((r) =>
|
||||
setRows(r.data)
|
||||
);
|
||||
}, []);
|
||||
}, [selectedPropertyId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
|
@ -51,12 +52,9 @@ export function ReferralCodesPage() {
|
|||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await apiPost("/referral-codes", {
|
||||
generate,
|
||||
code: generate ? undefined : custom,
|
||||
label,
|
||||
code,
|
||||
});
|
||||
setOpen(false);
|
||||
setLabel("");
|
||||
load();
|
||||
}
|
||||
|
||||
|
|
@ -80,32 +78,20 @@ export function ReferralCodesPage() {
|
|||
<DialogTitle>New referral code</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<Label>Custom code</Label>
|
||||
<Label>Enter name</Label>
|
||||
<Input
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
value={meta}
|
||||
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>
|
||||
)}
|
||||
<Button type="submit">Create</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
|
@ -120,7 +106,6 @@ export function ReferralCodesPage() {
|
|||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Redemptions</TableHead>
|
||||
<TableHead>Active</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
|
|
@ -129,11 +114,7 @@ export function ReferralCodesPage() {
|
|||
{rows.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono font-medium">{r.code}</TableCell>
|
||||
<TableCell>{r.label}</TableCell>
|
||||
<TableCell>
|
||||
{r.redemptionCount}
|
||||
{r.maxRedemptions != null && ` / ${r.maxRedemptions}`}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono font-medium">{r.meta}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={r.isActive ? "success" : "secondary"}>
|
||||
{r.isActive ? "Active" : "Off"}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,21 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
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 type { Room } from "@/lib/types";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
export function RoomsPage() {
|
||||
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
|
||||
|
|
@ -41,28 +50,43 @@ export function RoomsPage() {
|
|||
const [baseRate, setBaseRate] = useState("120");
|
||||
|
||||
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(() => {
|
||||
load();
|
||||
}, []);
|
||||
}, [selectedPropertyId]);
|
||||
|
||||
async function addRoom(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await apiPost<Room>("/rooms", {
|
||||
name,
|
||||
roomTypeSlug: slug,
|
||||
maxGuests: Number(maxGuests),
|
||||
baseRate: Number(baseRate),
|
||||
status: "available",
|
||||
floor: "",
|
||||
});
|
||||
setOpen(false);
|
||||
setName("");
|
||||
load();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiPost<Room>("/rooms", {
|
||||
name,
|
||||
roomType: slug,
|
||||
maxGuests: Number(maxGuests),
|
||||
baseRate: Number(baseRate),
|
||||
});
|
||||
setOpen(false);
|
||||
setName("");
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -118,7 +142,7 @@ export function RoomsPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit" loading={submitting}>Save</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user