first commit
This commit is contained in:
@@ -0,0 +1,780 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
CalendarPlusIcon,
|
||||
CheckIcon,
|
||||
DumbbellIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
cancelFacilitySession,
|
||||
createFacility,
|
||||
createFacilitySession,
|
||||
getFacilities,
|
||||
getFacilitySessions,
|
||||
setFacilityStatus,
|
||||
updateFacility,
|
||||
} from "@/lib/api/facilities"
|
||||
import type { Facility, FacilityInput, FacilitySession } from "@/lib/models/facility"
|
||||
import { useFetch } from "@/lib/use-fetch"
|
||||
import { useMutation, runMutation } from "@/lib/use-mutation"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { Spinner } from "@/components/ui/spinner"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||
import { PageHeader } from "@/components/hexa/page-header"
|
||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
||||
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
|
||||
|
||||
const FACILITY_TYPE_OPTIONS: { label: string; value: FacilityInput["facilityType"] }[] = [
|
||||
{ label: "Gym", value: "Gym" },
|
||||
{ label: "Swimming Pool", value: "Pool" },
|
||||
{ label: "Basketball Court", value: "Court" },
|
||||
{ label: "Class (e.g. Karate)", value: "Class" },
|
||||
]
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
|
||||
function toLocalInputValue(date: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function BookingsView() {
|
||||
const facilitiesFetch = useFetch(() => getFacilities(true))
|
||||
const facilities = facilitiesFetch.data ?? []
|
||||
|
||||
if (facilitiesFetch.loading) {
|
||||
return <PageSkeleton variant="stats-table" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Bookings"
|
||||
description="Facilities, scheduled sessions, the booking roster and time-bank cards"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="facilities" className="gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="facilities">Facilities</TabsTrigger>
|
||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
||||
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="facilities">
|
||||
<FacilitiesTab
|
||||
facilities={facilities}
|
||||
onReload={facilitiesFetch.reload}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sessions">
|
||||
<SessionsTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
<TabsContent value="roster">
|
||||
<BookingRosterTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
<TabsContent value="time-bank-cards">
|
||||
<TimeBankCardsTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Facilities ----------
|
||||
|
||||
interface FacilitiesTabProps {
|
||||
facilities: Facility[]
|
||||
onReload: () => void
|
||||
}
|
||||
|
||||
function FacilitiesTab({ facilities, onReload }: FacilitiesTabProps) {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [editTarget, setEditTarget] = React.useState<Facility | null>(null)
|
||||
const [statusTarget, setStatusTarget] = React.useState<Facility | null>(null)
|
||||
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
||||
|
||||
async function applyStatus(facility: Facility, isActive: boolean) {
|
||||
setStatusPendingId(facility.facilityId)
|
||||
try {
|
||||
await runMutation(() => setFacilityStatus(facility.facilityId, isActive))
|
||||
toast.success(isActive ? "Facility activated" : "Facility deactivated", {
|
||||
description: `"${facility.name}" is now ${isActive ? "active" : "inactive"}.`,
|
||||
})
|
||||
onReload()
|
||||
} catch {
|
||||
// runMutation() already toasted the real error.
|
||||
} finally {
|
||||
setStatusPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<Facility>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Facility",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<DumbbellIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.name}</p>
|
||||
{row.description && (
|
||||
<p className="text-xs text-muted-foreground">{row.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "type", header: "Type", cell: (row) => <Badge variant="outline">{row.facilityType}</Badge> },
|
||||
{ key: "capacity", header: "Default Capacity", cell: (row) => row.defaultCapacity },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (row) => (
|
||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||
{row.isActive ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "text-center",
|
||||
cell: (row) => (
|
||||
<div className="flex justify-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Edit facility</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
|
||||
disabled={statusPendingId === row.facilityId}
|
||||
onClick={() => setStatusTarget(row)}
|
||||
>
|
||||
{statusPendingId === row.facilityId ? (
|
||||
<Spinner className="size-4" />
|
||||
) : row.isActive ? (
|
||||
<XIcon className="text-muted-foreground" />
|
||||
) : (
|
||||
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{row.isActive ? "Deactivate facility" : "Activate facility"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditTarget(null)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
Add Facility
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={facilities}
|
||||
rowKey={(row) => row.facilityId}
|
||||
emptyMessage="No facilities yet. Add the gym, pool, court or a class to get started."
|
||||
renderMobileCard={(row) => (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<DumbbellIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.name}</p>
|
||||
<Badge variant="outline">{row.facilityType}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||
{row.isActive ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2.5">
|
||||
<span className="text-sm text-muted-foreground">Capacity: {row.defaultCapacity}</span>
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
|
||||
disabled={statusPendingId === row.facilityId}
|
||||
onClick={() => setStatusTarget(row)}
|
||||
>
|
||||
{statusPendingId === row.facilityId ? (
|
||||
<Spinner className="size-4" />
|
||||
) : row.isActive ? (
|
||||
<XIcon className="text-muted-foreground" />
|
||||
) : (
|
||||
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FacilityDialog
|
||||
open={dialogOpen || !!editTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogOpen(false)
|
||||
setEditTarget(null)
|
||||
}
|
||||
}}
|
||||
editTarget={editTarget}
|
||||
onSaved={onReload}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!statusTarget} onOpenChange={(open) => !open && setStatusTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{statusTarget?.isActive ? "Deactivate this facility?" : "Activate this facility?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{statusTarget?.isActive
|
||||
? `"${statusTarget?.name}" will stop accepting new bookings until reactivated.`
|
||||
: `"${statusTarget?.name}" will accept new bookings again.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
const facility = statusTarget
|
||||
setStatusTarget(null)
|
||||
if (facility) void applyStatus(facility, !facility.isActive)
|
||||
}}
|
||||
>
|
||||
{statusTarget?.isActive ? "Deactivate" : "Activate"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FacilityDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editTarget: Facility | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function FacilityDialog({ open, onOpenChange, editTarget, onSaved }: FacilityDialogProps) {
|
||||
const [name, setName] = React.useState("")
|
||||
const [facilityType, setFacilityType] = React.useState<FacilityInput["facilityType"]>("Gym")
|
||||
const [description, setDescription] = React.useState("")
|
||||
const [capacity, setCapacity] = React.useState("1")
|
||||
const { busy, run } = useMutation()
|
||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setName(editTarget?.name ?? "")
|
||||
setFacilityType(editTarget?.facilityType ?? "Gym")
|
||||
setDescription(editTarget?.description ?? "")
|
||||
setCapacity(String(editTarget?.defaultCapacity ?? 1))
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !!name.trim() && Number(capacity) >= 1 && !busy
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
const input: FacilityInput = {
|
||||
name: name.trim(),
|
||||
facilityType,
|
||||
description: description.trim() || undefined,
|
||||
defaultCapacity: Number(capacity),
|
||||
}
|
||||
try {
|
||||
await run(() =>
|
||||
editTarget ? updateFacility(editTarget.facilityId, input) : createFacility(input)
|
||||
)
|
||||
toast.success(editTarget ? "Facility updated" : "Facility created", {
|
||||
description: `"${input.name}" was saved.`,
|
||||
})
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch {
|
||||
// run() already toasted the real error; keep the dialog open for retry.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? "Edit Facility" : "Add Facility"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? "Update this facility's details." : "Add the gym, pool, court or a class."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="facility-name">
|
||||
Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="facility-name"
|
||||
placeholder="e.g. Main Gym Floor"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
items={FACILITY_TYPE_OPTIONS}
|
||||
value={facilityType}
|
||||
onValueChange={(v) => setFacilityType((v as FacilityInput["facilityType"]) ?? "Gym")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACILITY_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="facility-capacity">
|
||||
Default Capacity <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="facility-capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="facility-description">Description</Label>
|
||||
<Textarea
|
||||
id="facility-description"
|
||||
placeholder="Optional short description"
|
||||
maxLength={300}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
{editTarget ? "Save Changes" : "Add Facility"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
|
||||
interface SessionsTabProps {
|
||||
facilities: Facility[]
|
||||
}
|
||||
|
||||
function SessionsTab({ facilities }: SessionsTabProps) {
|
||||
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
||||
const sessionsFetch = useFetch(() =>
|
||||
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
|
||||
)
|
||||
const sessions = sessionsFetch.data ?? []
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [cancelTarget, setCancelTarget] = React.useState<FacilitySession | null>(null)
|
||||
const { busy: cancelling, run: runCancel } = useMutation()
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "All facilities", value: "all" },
|
||||
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
|
||||
]
|
||||
|
||||
async function confirmCancel() {
|
||||
if (!cancelTarget) return
|
||||
const target = cancelTarget
|
||||
setCancelTarget(null)
|
||||
try {
|
||||
await runCancel(() => cancelFacilitySession(target.facilitySessionId))
|
||||
toast.success("Session cancelled")
|
||||
sessionsFetch.reload()
|
||||
} catch {
|
||||
// run() already toasted the real error.
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<FacilitySession>[] = [
|
||||
{ key: "facility", header: "Facility", cell: (row) => row.facilityName ?? "-" },
|
||||
{ key: "start", header: "Start", cell: (row) => formatDateTime(row.startTime) },
|
||||
{ key: "end", header: "End", cell: (row) => formatDateTime(row.endTime) },
|
||||
{
|
||||
key: "capacity",
|
||||
header: "Booked / Capacity",
|
||||
cell: (row) => `${row.bookedCount} / ${row.capacity}`,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (row) =>
|
||||
row.isCancelled ? (
|
||||
<StatusBadge status="danger">Cancelled</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge status="success">Scheduled</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "text-center",
|
||||
cell: (row) =>
|
||||
row.isCancelled ? null : (
|
||||
<div className="flex justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Cancel session"
|
||||
onClick={() => setCancelTarget(row)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Cancel session</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Facility</Label>
|
||||
<Select items={filterOptions} value={facilityFilter} onValueChange={(v) => setFacilityFilter(v ?? "all")}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All facilities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)} disabled={facilities.length === 0}>
|
||||
<CalendarPlusIcon />
|
||||
Schedule Session
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sessionsFetch.loading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={sessions}
|
||||
rowKey={(row) => row.facilitySessionId}
|
||||
emptyMessage="No sessions scheduled yet."
|
||||
renderMobileCard={(row) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{row.facilityName ?? "-"}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
|
||||
</p>
|
||||
</div>
|
||||
{row.isCancelled ? (
|
||||
<StatusBadge status="danger">Cancelled</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge status="success">Scheduled</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.bookedCount} / {row.capacity} booked
|
||||
</span>
|
||||
{!row.isCancelled && (
|
||||
<Button variant="outline" size="icon-sm" aria-label="Cancel session" onClick={() => setCancelTarget(row)}>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScheduleSessionDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
facilities={facilities}
|
||||
onScheduled={sessionsFetch.reload}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel this session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Existing bookings for this session will need to be handled separately. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Session</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmCancel} disabled={cancelling}>
|
||||
Cancel Session
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScheduleSessionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
facilities: Facility[]
|
||||
onScheduled: () => void
|
||||
}
|
||||
|
||||
function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }: ScheduleSessionDialogProps) {
|
||||
const [facilityId, setFacilityId] = React.useState("")
|
||||
const [startTime, setStartTime] = React.useState("")
|
||||
const [endTime, setEndTime] = React.useState("")
|
||||
const [capacity, setCapacity] = React.useState("")
|
||||
const { busy, run } = useMutation()
|
||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
now.setHours(now.getHours() + 1)
|
||||
const later = new Date(now.getTime() + 60 * 60 * 1000)
|
||||
setFacilityId(facilities[0]?.facilityId ?? "")
|
||||
setStartTime(toLocalInputValue(now))
|
||||
setEndTime(toLocalInputValue(later))
|
||||
setCapacity("")
|
||||
}
|
||||
}
|
||||
|
||||
const facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
||||
const canSubmit = !!facilityId && !!startTime && !!endTime && endTime > startTime && !busy
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
try {
|
||||
await run(() =>
|
||||
createFacilitySession({
|
||||
facilityId,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
endTime: new Date(endTime).toISOString(),
|
||||
capacity: capacity ? Number(capacity) : undefined,
|
||||
})
|
||||
)
|
||||
toast.success("Session scheduled")
|
||||
onOpenChange(false)
|
||||
onScheduled()
|
||||
} catch {
|
||||
// run() already toasted the real error; keep the dialog open for retry.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Session</DialogTitle>
|
||||
<DialogDescription>Open a bookable time slot for a facility.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
Facility <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
items={facilityOptions}
|
||||
value={facilityId}
|
||||
onValueChange={(v) => setFacilityId(v ?? "")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select facility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{facilityOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="session-start">
|
||||
Start <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="session-start"
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="session-end">
|
||||
End <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="session-end"
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="session-capacity">Capacity override</Label>
|
||||
<Input
|
||||
id="session-capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Uses the facility's default capacity if left blank"
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
Schedule Session
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export { BookingsView }
|
||||
Reference in New Issue
Block a user