first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:48:14 +05:30
commit a1b870fa8a
242 changed files with 39483 additions and 0 deletions
+780
View File
@@ -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 &amp; 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 }