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
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="chart-list" />
}
+5
View File
@@ -0,0 +1,5 @@
import { AttendanceView } from "@/components/hexa/views/attendance-view"
export default function AttendancePage() {
return <AttendanceView />
}
+5
View File
@@ -0,0 +1,5 @@
import { BookingsView } from "@/components/hexa/views/bookings-view"
export default function BookingsPage() {
return <BookingsView />
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
@@ -0,0 +1,5 @@
import { CustomerPaymentsView } from "@/components/hexa/views/customer-payments-view"
export default function CustomerPaymentsPage() {
return <CustomerPaymentsView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-cards" />
}
+5
View File
@@ -0,0 +1,5 @@
import { FeedView } from "@/components/hexa/views/feed-view"
export default function FeedPage() {
return <FeedView />
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import * as React from "react"
import { usePathname, useRouter } from "next/navigation"
import { isLoggedIn } from "@/lib/auth/token-service"
import { Spinner } from "@/components/ui/spinner"
import { DashboardShell } from "@/components/hexa/dashboard-shell"
import { ClientProfileProvider } from "@/components/hexa/client-profile-context"
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const [checked, setChecked] = React.useState(false)
React.useEffect(() => {
if (!isLoggedIn()) {
router.replace(`/login?next=${encodeURIComponent(pathname || "/dashboard")}`)
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time auth-gate check against localStorage, not derived from props/state
setChecked(true)
}, [router, pathname])
if (!checked) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner className="size-6" />
</div>
)
}
return (
<ClientProfileProvider>
<DashboardShell notificationCount={3}>{children}</DashboardShell>
</ClientProfileProvider>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="chart-list" />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { MealPlansView } from "@/components/hexa/views/meal-plans-view"
export default function MealPlansPage() {
return <MealPlansView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { MembersView } from "@/components/hexa/views/members-view"
export default function MembersPage() {
return <MembersView />
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-cards" />
}
+5
View File
@@ -0,0 +1,5 @@
import { MembershipsView } from "@/components/hexa/views/memberships-view"
export default function MembershipsPage() {
return <MembershipsView />
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardOverviewView } from "@/components/hexa/views/dashboard-overview-view"
export default function DashboardOverviewPage() {
return <DashboardOverviewView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="chart-list" />
}
+5
View File
@@ -0,0 +1,5 @@
import { ReportsView } from "@/components/hexa/views/reports-view"
export default function ReportsPage() {
return <ReportsView />
}
+11
View File
@@ -0,0 +1,11 @@
import * as React from "react"
import { SettingsView } from "@/components/hexa/views/settings-view"
export default function SettingsPage() {
return (
<React.Suspense fallback={null}>
<SettingsView />
</React.Suspense>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { StaffView } from "@/components/hexa/views/staff-view"
export default function StaffPage() {
return <StaffView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { TicketsView } from "@/components/hexa/views/tickets-view"
export default function TicketsPage() {
return <TicketsView />
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { WorkoutPlansView } from "@/components/hexa/views/workout-plans-view"
export default function WorkoutPlansPage() {
return <WorkoutPlansView />
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+206
View File
@@ -0,0 +1,206 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeftIcon, Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import hexaLogo from "@/assets/images/hexa-logo.png"
import { ApiError } from "@/lib/api/core-http"
import { forgotPassword, resetPassword, verifyResetOtp } from "@/lib/api/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
type Step = "identifier" | "otp" | "password"
export default function ForgotPasswordPage() {
const router = useRouter()
const [step, setStep] = React.useState<Step>("identifier")
const [identifier, setIdentifier] = React.useState("")
const [referenceNumber, setReferenceNumber] = React.useState("")
const [otp, setOtp] = React.useState("")
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
function onError(error: unknown, fallback: string) {
toast.error(error instanceof ApiError ? error.message : fallback)
}
async function handleSendOtp(e: React.FormEvent) {
e.preventDefault()
if (!identifier.trim()) {
toast.error("Enter your email or mobile number.")
return
}
setSubmitting(true)
try {
const { referenceNumber } = await forgotPassword(identifier.trim())
setReferenceNumber(referenceNumber)
setStep("otp")
toast.success("We sent you a verification code.")
} catch (error) {
onError(error, "Couldn't send the code. Try again.")
} finally {
setSubmitting(false)
}
}
async function handleVerifyOtp(e: React.FormEvent) {
e.preventDefault()
if (otp.length < 6) {
toast.error("Enter the 6-digit code.")
return
}
setSubmitting(true)
try {
await verifyResetOtp(referenceNumber, otp)
setStep("password")
} catch (error) {
onError(error, "That code didn't work. Try again.")
} finally {
setSubmitting(false)
}
}
async function handleReset(e: React.FormEvent) {
e.preventDefault()
if (password.length < 8) {
toast.error("Password must be at least 8 characters.")
return
}
if (password !== confirm) {
toast.error("Passwords don't match.")
return
}
setSubmitting(true)
try {
await resetPassword(referenceNumber, password, confirm)
toast.success("Password reset. Please sign in.")
router.push("/login")
} catch (error) {
onError(error, "Couldn't reset your password. Try again.")
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen">
<div className="relative hidden w-1/2 shrink-0 bg-neutral-50 lg:block">
<Image src="/images/auth-bg.png" alt="" fill className="object-contain object-bottom p-12" priority />
<div className="absolute top-8 left-8 flex items-center gap-2">
<Image src={hexaLogo} alt="Vijaya Sport Club" width={260} height={64} className="h-16 w-auto" />
</div>
</div>
<div className="flex w-full flex-col justify-start px-6 py-10 sm:justify-center sm:px-12 sm:py-12 lg:w-1/2 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<Link href="/login" className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeftIcon className="size-4" />
Back to sign in
</Link>
<p className="text-xs font-semibold tracking-wide text-primary uppercase">Reset password</p>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
{step === "identifier" && "Forgot your password?"}
{step === "otp" && "Enter the code"}
{step === "password" && "Choose a new password"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{step === "identifier" && "Enter your email or mobile and we'll send you a verification code."}
{step === "otp" && `We sent a 6-digit code to ${identifier}.`}
{step === "password" && "Pick a strong password you haven't used before."}
</p>
{step === "identifier" && (
<form onSubmit={handleSendOtp} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="identifier">Email or mobile number</Label>
<Input
id="identifier"
className="h-11"
placeholder="you@example.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
/>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Send code
</Button>
</form>
)}
{step === "otp" && (
<form onSubmit={handleVerifyOtp} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col items-center gap-2">
<InputOTP maxLength={6} value={otp} onChange={setOtp} containerClassName="justify-center">
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} className="size-10 text-base sm:size-11" />
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Verify code
</Button>
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground"
onClick={() => setStep("identifier")}
>
Use a different account
</button>
</form>
)}
{step === "password" && (
<form onSubmit={handleReset} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
className="h-11"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
className="h-11"
placeholder="Re-enter your password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Reset password
</Button>
</form>
)}
</div>
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.984 0.003 17);
--foreground: oklch(0.2 0.01 17);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.01 17);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.01 17);
--primary: oklch(0.586 0.216 17);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.965 0.004 17);
--secondary-foreground: oklch(0.2 0.01 17);
--muted: oklch(0.965 0.004 17);
--muted-foreground: oklch(0.54 0.01 17);
--accent: oklch(0.955 0.03 17);
--accent-foreground: oklch(0.586 0.216 17);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.006 17);
--input: oklch(0.92 0.006 17);
--ring: oklch(0.586 0.216 17 / 40%);
--chart-1: oklch(0.586 0.216 17);
--chart-2: oklch(0.65 0.15 220);
--chart-3: oklch(0.72 0.15 150);
--chart-4: oklch(0.75 0.15 70);
--chart-5: oklch(0.6 0.18 290);
--radius: 0.75rem;
--sidebar: oklch(1 0 0);
--sidebar-foreground: oklch(0.2 0.01 17);
--sidebar-primary: oklch(0.586 0.216 17);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.955 0.03 17);
--sidebar-accent-foreground: oklch(0.586 0.216 17);
--sidebar-border: oklch(0.94 0.005 17);
--sidebar-ring: oklch(0.586 0.216 17 / 40%);
}
.dark {
--background: oklch(0.17 0.006 17);
--foreground: oklch(0.96 0 0);
--card: oklch(0.215 0.008 17);
--card-foreground: oklch(0.96 0 0);
--popover: oklch(0.215 0.008 17);
--popover-foreground: oklch(0.96 0 0);
--primary: oklch(0.645 0.211 17);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.28 0.008 17);
--secondary-foreground: oklch(0.96 0 0);
--muted: oklch(0.28 0.008 17);
--muted-foreground: oklch(0.68 0.01 17);
--accent: oklch(0.32 0.05 17);
--accent-foreground: oklch(0.9 0.06 17);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.645 0.211 17 / 45%);
--chart-1: oklch(0.645 0.211 17);
--chart-2: oklch(0.65 0.15 220);
--chart-3: oklch(0.72 0.15 150);
--chart-4: oklch(0.75 0.15 70);
--chart-5: oklch(0.6 0.18 290);
--sidebar: oklch(0.19 0.008 17);
--sidebar-foreground: oklch(0.96 0 0);
--sidebar-primary: oklch(0.645 0.211 17);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.28 0.05 17);
--sidebar-accent-foreground: oklch(0.9 0.06 17);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.645 0.211 17 / 45%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { ModalStackProvider } from "@/components/modal-stack";
const geistSans = Geist({
variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "HexaFitness | Fitness Automation Simplified",
description: "Gym management dashboard for HexaFitness.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col"
data-new-gr-c-s-check-loaded="14.1309.0"
data-gr-ext-installed=""
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
<TooltipProvider delay={200}>
<ModalStackProvider>
{children}
<Toaster position="top-right" richColors />
</ModalStackProvider>
</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import hexaLogo from "@/assets/images/hexa-logo.png"
import { ApiError } from "@/lib/api/core-http"
import { login } from "@/lib/api/auth"
import { resetNavItemsCache } from "@/hooks/use-nav-items"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const next = searchParams.get("next") || "/dashboard"
const [identifier, setIdentifier] = React.useState("")
const [password, setPassword] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [rememberMe, setRememberMe] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!identifier.trim() || !password) {
toast.error("Enter your email/phone and password.")
return
}
setSubmitting(true)
try {
const result = await login({ identifier: identifier.trim(), password, rememberMe })
if (!result.accessToken) {
toast.error("Login didn't return a valid session. Try again.")
return
}
resetNavItemsCache()
router.push(next)
} catch (error) {
const message = error instanceof ApiError ? error.message : "Couldn't sign in. Try again."
toast.error(message)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen">
<div className="relative hidden w-1/2 shrink-0 bg-neutral-50 lg:block">
<Image src="/images/auth-bg.png" alt="" fill className="object-contain object-bottom p-12" priority />
</div>
<div className="flex w-full flex-col justify-start px-6 py-10 sm:justify-center sm:px-12 sm:py-12 lg:w-1/2 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<div className="mb-8 flex items-center gap-2 lg:hidden">
<Image src={hexaLogo} alt="Vijaya Sport Club" width={230} height={56} className="h-14 w-auto" />
</div>
<p className="text-xs font-semibold tracking-wide text-primary uppercase">Sign in</p>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Welcome back</h1>
<p className="mt-2 text-sm text-muted-foreground">Enter your details to access your gym.</p>
<form onSubmit={handleSubmit} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="identifier">Email or mobile number</Label>
<Input
id="identifier"
className="h-11"
placeholder="you@example.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-xs font-medium text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
className="h-11"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
Remember me for 30 days
</label>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Sign in
</Button>
</form>
</div>
</div>
</div>
)
}
export default function LoginPage() {
return (
<React.Suspense fallback={null}>
<LoginForm />
</React.Suspense>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation"
// No public marketing site for a single-club internal admin portal -- straight to the
// dashboard (which itself redirects to /login if there's no stored token).
export default function RootPage() {
redirect("/dashboard")
}
+172
View File
@@ -0,0 +1,172 @@
"use client"
import * as React from "react"
import { useParams } from "next/navigation"
import { CheckCircle2Icon, InfoIcon, Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import { getInvoiceByPaymentToken, payInvoiceByToken } from "@/lib/api/pay"
import { ApiError } from "@/lib/api/core-http"
import { formatCurrency } from "@/lib/format"
import type { PayByLinkInvoice } from "@/lib/models/pay"
import { OnboardingLayout } from "@/components/onboarding/onboarding-layout"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
type Step = "loading" | "form" | "paying" | "success" | "error"
export default function PayByLinkPage() {
const params = useParams<{ token: string }>()
const token = params.token
const [step, setStep] = React.useState<Step>("loading")
const [invoice, setInvoice] = React.useState<PayByLinkInvoice | null>(null)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const [cardName, setCardName] = React.useState("")
const [cardNumber, setCardNumber] = React.useState("")
const [cardExpiry, setCardExpiry] = React.useState("")
const [cardCvc, setCardCvc] = React.useState("")
React.useEffect(() => {
let cancelled = false
async function load() {
try {
const result = await getInvoiceByPaymentToken(token)
if (cancelled) return
setInvoice(result)
setStep(result.balance > 0 ? "form" : "success")
} catch (error) {
if (cancelled) return
const message = error instanceof ApiError ? error.message : "This payment link is invalid or has expired."
setErrorMessage(message)
setStep("error")
}
}
load()
return () => {
cancelled = true
}
}, [token])
async function handlePay(e: React.FormEvent) {
e.preventDefault()
if (!cardNumber.trim() || !cardExpiry.trim() || !cardCvc.trim() || !cardName.trim()) {
toast.error("Fill in the card details.")
return
}
setStep("paying")
try {
// No real PayHere/Stripe SDK is wired up yet — this is a mock token standing in
// for what real client-side card tokenization would return.
const cardToken = `mock_${crypto.randomUUID()}`
await payInvoiceByToken(token, cardToken)
setStep("success")
} catch (error) {
const message = error instanceof ApiError ? error.message : "Couldn't process the payment. Try again."
toast.error(message)
setStep("form")
}
}
return (
<OnboardingLayout eyebrow="Payment" currentStep={1} totalSteps={1} hideProgress>
{step === "loading" && (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<Loader2Icon className="size-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading your invoice…</p>
</div>
)}
{step === "error" && (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Link unavailable</h1>
<p className="mt-2 text-sm text-muted-foreground">{errorMessage}</p>
</>
)}
{(step === "form" || step === "paying") && invoice && (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
{invoice.gymName ?? "Pay your invoice"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{invoice.planName ? `${invoice.planName} — ` : ""}
Outstanding balance: <span className="font-semibold text-foreground">{formatCurrency(invoice.balance)}</span>
</p>
<Alert className="mt-4">
<InfoIcon />
<AlertDescription>
Test mode — no real PayHere/Stripe SDK is wired up yet, so no real charge will occur.
</AlertDescription>
</Alert>
<form onSubmit={handlePay} className="mt-6 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-name">Name on card</Label>
<Input
id="card-name"
placeholder="Full name"
className="h-11"
value={cardName}
onChange={(e) => setCardName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-number">Card number</Label>
<Input
id="card-number"
inputMode="numeric"
placeholder="4111 1111 1111 1111"
className="h-11"
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-expiry">Expiry</Label>
<Input
id="card-expiry"
placeholder="MM/YY"
className="h-11"
value={cardExpiry}
onChange={(e) => setCardExpiry(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-cvc">CVC</Label>
<Input
id="card-cvc"
inputMode="numeric"
placeholder="123"
className="h-11"
value={cardCvc}
onChange={(e) => setCardCvc(e.target.value)}
/>
</div>
</div>
<Button type="submit" size="lg" className="h-11" disabled={step === "paying"}>
{step === "paying" && <Loader2Icon className="animate-spin" />}
Pay {formatCurrency(invoice.balance)}
</Button>
</form>
</>
)}
{step === "success" && (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<CheckCircle2Icon className="size-12 text-emerald-600" />
<h1 className="text-2xl font-bold tracking-tight">Payment received</h1>
<p className="text-sm text-muted-foreground">
Thanks{invoice?.memberName ? `, ${invoice.memberName}` : ""} — your payment has been recorded.
</p>
</div>
)}
</OnboardingLayout>
)
}
+151
View File
@@ -0,0 +1,151 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { CheckCircle2Icon, Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import hexaLogo from "@/assets/images/hexa-logo.png"
import { ApiError } from "@/lib/api/core-http"
import { resetPasswordWithToken } from "@/lib/api/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token") ?? ""
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [done, setDone] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (password.length < 8) {
toast.error("Password must be at least 8 characters.")
return
}
if (password !== confirm) {
toast.error("Passwords don't match.")
return
}
setSubmitting(true)
try {
await resetPasswordWithToken(token, password, confirm)
setDone(true)
toast.success("Password set. Please sign in.")
} catch (error) {
const message =
error instanceof ApiError
? error.message
: "Couldn't reset your password. Try again."
toast.error(message)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen">
<div className="relative hidden w-1/2 shrink-0 bg-neutral-50 lg:block">
<Image src="/images/auth-bg.png" alt="" fill className="object-contain object-bottom p-12" priority />
<div className="absolute top-8 left-8 flex items-center gap-2">
<Image src={hexaLogo} alt="Vijaya Sport Club" width={260} height={64} className="h-16 w-auto" />
</div>
</div>
<div className="flex w-full flex-col justify-start px-6 py-10 sm:justify-center sm:px-12 sm:py-12 lg:w-1/2 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Set password</p>
{!token ? (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Invalid link</h1>
<p className="mt-2 text-sm text-muted-foreground">
This link is missing its token. Request a new password reset email and try again.
</p>
<Button
size="lg"
className="mt-8 h-11 w-full"
nativeButton={false}
render={<Link href="/forgot-password" />}
>
Request a new link
</Button>
</>
) : done ? (
<>
<div className="mt-2 flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl">
<CheckCircle2Icon className="size-7 text-primary" />
Password set
</div>
<p className="mt-2 text-sm text-muted-foreground">
Your password has been updated. Sign in with your new password to continue.
</p>
<Button size="lg" className="mt-8 h-11 w-full" onClick={() => router.push("/login")}>
Go to sign in
</Button>
</>
) : (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Choose a new password</h1>
<p className="mt-2 text-sm text-muted-foreground">
Pick a strong password you haven&apos;t used before.
</p>
<form onSubmit={handleSubmit} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
className="h-11"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
className="h-11"
placeholder="Re-enter your password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Set password
</Button>
</form>
</>
)}
<p className="mt-8 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-foreground hover:underline">
Back to sign in
</Link>
</p>
</div>
</div>
</div>
)
}
export default function ResetPasswordPage() {
return (
<React.Suspense fallback={null}>
<ResetPasswordForm />
</React.Suspense>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Loader2Icon, MapPinIcon } from "lucide-react"
import { searchAddress, type AddressSuggestion } from "@/lib/geocode"
import { Input } from "@/components/ui/input"
const DEBOUNCE_MS = 400
interface AddressAutocompleteProps {
id?: string
className?: string
placeholder?: string
value: string
onChange: (value: string) => void
/** Fired when the user picks a suggestion — carries the resolved lat/lng. */
onSelect: (suggestion: AddressSuggestion) => void
}
function AddressAutocomplete({
id,
className,
placeholder,
value,
onChange,
onSelect,
}: AddressAutocompleteProps) {
const [suggestions, setSuggestions] = React.useState<AddressSuggestion[]>([])
const [loading, setLoading] = React.useState(false)
const [open, setOpen] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
const skipNextSearch = React.useRef(false)
React.useEffect(() => {
if (skipNextSearch.current) {
skipNextSearch.current = false
return
}
if (value.trim().length < 3) {
setSuggestions([])
setLoading(false)
return
}
let cancelled = false
setLoading(true)
const timer = setTimeout(async () => {
const results = await searchAddress(value)
if (!cancelled) {
setSuggestions(results)
setLoading(false)
setOpen(true)
}
}, DEBOUNCE_MS)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [value])
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
function handleSelect(suggestion: AddressSuggestion) {
skipNextSearch.current = true
setSuggestions([])
setOpen(false)
onChange(suggestion.label)
onSelect(suggestion)
}
return (
<div ref={containerRef} className="relative">
<Input
id={id}
placeholder={placeholder}
className={className}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => suggestions.length > 0 && setOpen(true)}
autoComplete="off"
/>
{loading && (
<Loader2Icon className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
{open && suggestions.length > 0 && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border bg-popover shadow-md">
{suggestions.map((s, i) => (
<button
key={`${s.lat}-${s.lng}-${i}`}
type="button"
onClick={() => handleSelect(s)}
className="flex w-full items-start gap-2 px-3 py-2 text-left text-sm hover:bg-muted"
>
<MapPinIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{s.label}</span>
</button>
))}
</div>
)}
</div>
)
}
export { AddressAutocomplete }
+110
View File
@@ -0,0 +1,110 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { MapPinIcon } from "lucide-react"
import { NAV_ITEMS, isNavItemActive } from "@/lib/nav"
import { useNavItems } from "@/hooks/use-nav-items"
import { useClientProfile } from "@/components/hexa/client-profile-context"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
interface AppSidebarProps {
/** Sandbox demo mode: navigate by key with local state instead of route links. */
activeKey?: string
onNavigate?: (key: string) => void
}
function AppSidebar({ activeKey, onNavigate }: AppSidebarProps) {
const pathname = usePathname()
const { setOpenMobile } = useSidebar()
const profile = useClientProfile()
// Sandbox demo mode (onNavigate present, no backend to call) keeps the static list;
// real dashboard usage fetches the live, permission-filtered nav.
const { items: liveItems } = useNavItems(!onNavigate)
const items = onNavigate ? NAV_ITEMS : liveItems
const gymName = profile.gymName ?? "HexaFitness Gym"
const location = [profile.city, profile.address && profile.city ? null : profile.address]
.filter(Boolean)
.join(", ") || "Sri Lanka"
const gymInitials = gymName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("")
return (
<Sidebar collapsible="offcanvas">
<SidebarHeader className="px-3 py-4">
<div data-tour="member-app-preview" className="flex items-center gap-2.5 px-1">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-primary/10 text-primary ring-1 ring-foreground/10">
{profile.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- base64 data URL, not an optimizable remote asset
<img src={profile.logoUrl} alt={gymName} className="size-full object-cover" />
) : (
<span className="text-sm font-semibold">{gymInitials || "GY"}</span>
)}
</div>
<div className="min-w-0 leading-tight">
<p className="truncate text-base font-bold">{gymName}</p>
<p className="flex items-center gap-1 truncate text-[0.65rem] text-muted-foreground">
<MapPinIcon className="size-3 shrink-0" />
<span className="truncate">{location}</span>
</p>
</div>
</div>
</SidebarHeader>
<SidebarContent className="px-2">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const active = onNavigate
? item.key === activeKey
: isNavItemActive(item, pathname)
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
data-tour={`nav-${item.key}`}
isActive={active}
onClick={() => {
setOpenMobile(false)
onNavigate?.(item.key)
}}
render={onNavigate ? undefined : <Link href={item.href} />}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="items-center p-3">
<p className="text-center text-[0.65rem] text-muted-foreground">
Professionally developed by <span className="font-medium text-foreground/70">Hexdive</span>
</p>
</SidebarFooter>
</Sidebar>
)
}
export { AppSidebar }
@@ -0,0 +1,83 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import type { AttendanceCount } from "@/lib/models/attendance"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
const chartConfig = {
attendanceCount: {
label: "Check-ins",
color: "var(--primary)",
},
} satisfies ChartConfig
interface AttendanceChartCardProps {
data: AttendanceCount[] | null
loading?: boolean
title?: string
/** Show at most this many members (sorted by count). */
limit?: number
}
function AttendanceChartCard({
data,
loading,
title = "Most active members",
limit = 8,
}: AttendanceChartCardProps) {
const rows = (data ?? [])
.slice()
.sort((a, b) => b.attendanceCount - a.attendanceCount)
.slice(0, limit)
.map((row) => ({
...row,
shortName:
(row.customerName ?? "?").length > 12
? `${row.customerName.slice(0, 11)}…`
: row.customerName,
}))
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-56 w-full rounded-lg" />
) : rows.length === 0 ? (
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
No attendance recorded for this period.
</div>
) : (
<ChartContainer config={chartConfig} className="h-56 w-full">
<BarChart data={rows} layout="vertical" margin={{ left: 8, right: 8 }}>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey="shortName"
width={90}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="attendanceCount" fill="var(--color-attendanceCount)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
export { AttendanceChartCard }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import { useRouter } from "next/navigation"
import { ArrowLeftIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface BackButtonProps {
label?: string
className?: string
}
function BackButton({ label, className }: BackButtonProps) {
const router = useRouter()
return (
<Button
variant="outline"
size={label ? "default" : "icon"}
className={cn("rounded-full", className)}
onClick={() => router.back()}
aria-label="Go back"
>
<ArrowLeftIcon />
{label}
</Button>
)
}
export { BackButton }
+86
View File
@@ -0,0 +1,86 @@
"use client"
import { CalendarCheck } from "lucide-react"
import { formatTime } from "@/lib/format"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export interface CheckinEntry {
name: string
/** ISO timestamp of the check-in/out event. */
time: string
/** Optional event label, e.g. "IN" / "OUT". */
eventType?: string
}
interface CheckinListProps {
entries: CheckinEntry[] | null
loading?: boolean
title?: string
emptyMessage?: string
}
function CheckinList({
entries,
loading,
title = "Today's check-ins",
emptyMessage = "No check-ins yet today.",
}: CheckinListProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))
) : !entries || entries.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarCheck className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
entries.map((entry, i) => (
<div key={`${entry.name}-${entry.time}-${i}`} className="flex items-center gap-3 py-2">
<Avatar className="size-9">
<AvatarFallback className="bg-primary/10 text-xs font-semibold text-primary">
{initials(entry.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 leading-tight">
<p className="truncate text-sm font-medium">{entry.name}</p>
<p className="text-xs text-muted-foreground">{formatTime(entry.time)}</p>
</div>
{entry.eventType && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground uppercase">
{entry.eventType}
</span>
)}
</div>
))
)}
</CardContent>
</Card>
)
}
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
}
export { CheckinList }
@@ -0,0 +1,85 @@
"use client"
import * as React from "react"
import { getGymLogo, getMyProfile } from "@/lib/api/profile"
interface ClientProfileState {
clientId: string | null
clientName: string | null
gymName: string | null
city: string | null
address: string | null
phone: string | null
email: string | null
logoUrl: string | null
loading: boolean
}
const ClientProfileContext = React.createContext<ClientProfileState>({
clientId: null,
clientName: null,
gymName: null,
city: null,
address: null,
phone: null,
email: null,
logoUrl: null,
loading: true,
})
function ClientProfileProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = React.useState<ClientProfileState>({
clientId: null,
clientName: null,
gymName: null,
city: null,
address: null,
phone: null,
email: null,
logoUrl: null,
loading: true,
})
React.useEffect(() => {
let cancelled = false
Promise.all([getMyProfile(), getGymLogo().catch(() => ({ logo: null }))])
.then(([profile, logoResult]) => {
if (cancelled) return
if (profile.clientId) {
try {
localStorage.setItem("clientId", profile.clientId)
} catch {
// ignore (e.g. private browsing storage restrictions)
}
}
const business = profile.clientBusinesses?.[0]
setState({
clientId: profile.clientId ?? null,
clientName: profile.clientName ?? null,
gymName: business?.businessName ?? profile.clientName ?? null,
city: business?.city ?? null,
address: business?.address ?? null,
phone: profile.clientPhone ?? null,
email: profile.clientEmail ?? null,
logoUrl: logoResult.logo ?? null,
loading: false,
})
})
.catch(() => {
// Best-effort chrome — leave fallbacks in place if the profile can't load.
if (!cancelled) setState((s) => ({ ...s, loading: false }))
})
return () => {
cancelled = true
}
}, [])
return <ClientProfileContext.Provider value={state}>{children}</ClientProfileContext.Provider>
}
function useClientProfile() {
return React.useContext(ClientProfileContext)
}
export { ClientProfileProvider, useClientProfile }
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/hexa/app-sidebar"
import { MobileNav } from "@/components/hexa/mobile-nav"
import { SiteHeader } from "@/components/hexa/site-header"
interface DashboardShellProps {
/** Sandbox demo mode: navigate by key with local state instead of route links. */
activeKey?: string
onNavigate?: (key: string) => void
onRefresh?: () => void
notificationCount?: number
children: React.ReactNode
}
function DashboardShell({
activeKey,
onNavigate,
onRefresh,
notificationCount,
children,
}: DashboardShellProps) {
const [date, setDate] = React.useState<Date | null>(null)
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- avoids rendering a time-dependent value during hydration.
setDate(new Date())
}, [])
return (
<SidebarProvider>
<AppSidebar activeKey={activeKey} onNavigate={onNavigate} />
<SidebarInset>
<SiteHeader
date={date}
onDateChange={setDate}
onRefresh={onRefresh}
notificationCount={notificationCount}
demo={Boolean(onNavigate)}
onNavigate={onNavigate}
/>
<main
data-tour="dashboard"
className="flex-1 p-3 pb-24 sm:p-4 sm:pb-24 md:pb-6 lg:p-6"
>
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">{children}</div>
</main>
<MobileNav activeKey={activeKey} onNavigate={onNavigate} />
</SidebarInset>
</SidebarProvider>
)
}
export { DashboardShell }
+101
View File
@@ -0,0 +1,101 @@
import { InboxIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface DataTableColumn<T> {
key: string
header: string
cell: (row: T) => React.ReactNode
className?: string
}
interface DataTableProps<T> {
columns: DataTableColumn<T>[]
rows: T[]
rowKey: (row: T) => string
renderMobileCard: (row: T) => React.ReactNode
emptyMessage?: string
className?: string
onRowClick?: (row: T) => void
}
function DataTable<T>({
columns,
rows,
rowKey,
renderMobileCard,
emptyMessage = "No results found.",
className,
onRowClick,
}: DataTableProps<T>) {
if (rows.length === 0) {
return (
<div className={cn("flex flex-col items-center justify-center gap-2 rounded-xl border bg-card py-16 text-center", className)}>
<InboxIcon className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
)
}
return (
<div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
<div className="flex flex-col divide-y md:hidden">
{rows.map((row) => (
<div
key={rowKey(row)}
className={cn("p-4", onRowClick && "cursor-pointer")}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{renderMobileCard(row)}
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((col) => (
<TableHead
key={col.key}
className={cn(
"h-11 text-xs font-medium tracking-wide text-muted-foreground uppercase",
col.className
)}
>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow
key={rowKey(row)}
className={onRowClick ? "cursor-pointer" : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => (
<TableCell key={col.key} className={col.className}>
{col.cell(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export { DataTable }
export type { DataTableColumn }
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { useMediaQuery } from "@/lib/use-media-query"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface DetailPanelProps {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
description?: React.ReactNode
children: React.ReactNode
}
/** Responsive detail container: bottom sheet on phones, dialog at md+. */
function DetailPanel({ open, onOpenChange, title, description, children }: DetailPanelProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[85vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<div className="px-4 pb-2">{children}</div>
</SheetContent>
</Sheet>
)
}
export { DetailPanel }
@@ -0,0 +1,137 @@
"use client"
import * as React from "react"
import { getAccessDevices } from "@/lib/api/access-devices"
import type { AccessDevice } from "@/lib/models/access-device"
import {
subscribeAccessEvents,
type DevicePresencePayload,
} from "@/lib/realtime/access-events"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useClientProfile } from "@/components/hexa/client-profile-context"
function DeviceStatusIndicator() {
const { clientId } = useClientProfile()
const [devices, setDevices] = React.useState<AccessDevice[] | null>(null)
React.useEffect(() => {
if (!clientId) return
let cancelled = false
async function load() {
try {
const result = await getAccessDevices()
if (!cancelled) setDevices(result.devices ?? [])
} catch {
// Best-effort chrome — keep the last known status rather than flashing an error.
}
}
void load()
const unsubscribe = subscribeAccessEvents((event) => {
if (event.type !== "devicePresence") return
const update = event.payload as DevicePresencePayload
setDevices((current) => {
if (!current) return current
const byId = new Map(
update.devices.map((device) => [device.deviceId, device])
)
return current.map((device) => {
const presence = byId.get(device.deviceId)
return presence ? { ...device, ...presence } : device
})
})
})
return () => {
cancelled = true
unsubscribe()
}
}, [clientId])
// No network polling: expire an online state locally if three heartbeats are
// missed. The next socket heartbeat restores it immediately.
React.useEffect(() => {
const expirations = (devices ?? [])
.filter((device) => device.isOnline && device.presenceExpiresAt)
.map((device) => Date.parse(device.presenceExpiresAt!))
.filter(Number.isFinite)
if (expirations.length === 0) return
const nextExpiration = Math.min(...expirations)
const timer = window.setTimeout(() => {
const now = Date.now()
setDevices((current) =>
current?.map((device) =>
device.isOnline &&
device.presenceExpiresAt &&
Date.parse(device.presenceExpiresAt) <= now
? { ...device, isOnline: false, presenceExpiresAt: null }
: device
) ?? current
)
}, Math.max(0, nextExpiration - Date.now()) + 50)
return () => window.clearTimeout(timer)
}, [devices])
const claimedDevices = devices?.filter((device) => device.isClaimed) ?? []
if (claimedDevices.length === 0) return null
const onlineCount = claimedDevices.filter((device) => device.isOnline).length
const allOnline = onlineCount === claimedDevices.length
const someOnline = onlineCount > 0
const statusLabel = allOnline
? claimedDevices.length === 1
? "Device online"
: "All devices online"
: someOnline
? `${onlineCount}/${claimedDevices.length} devices online`
: claimedDevices.length === 1
? "Device offline"
: "All devices offline"
return (
<Tooltip>
<TooltipTrigger
render={
<span className="flex cursor-default items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs font-medium">
<span
className={cn(
"size-2 shrink-0 rounded-full",
allOnline
? "bg-emerald-500"
: someOnline
? "bg-amber-500"
: "bg-destructive"
)}
/>
<span className="hidden sm:inline text-muted-foreground">
{statusLabel}
</span>
</span>
}
/>
<TooltipContent>
<div className="flex flex-col gap-1">
{claimedDevices.map((d, i) => (
<div key={d.deviceId ?? i} className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
d.isOnline ? "bg-emerald-500" : "bg-destructive"
)}
/>
<span>{d.deviceName ?? d.deviceId ?? "Device"}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
export { DeviceStatusIndicator }
+120
View File
@@ -0,0 +1,120 @@
"use client"
import { FilterIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface FilterSelectConfig {
key: string
label: string
value: string
placeholder: string
options: { label: string; value: string }[]
onChange: (value: string) => void
}
interface FilterBarProps {
searchLabel?: string
searchPlaceholder?: string
searchValue: string
onSearchChange: (value: string) => void
filters?: FilterSelectConfig[]
onFiltersClick?: () => void
resultsShowing?: number
resultsTotal?: number
onClear?: () => void
className?: string
}
function FilterBar({
searchLabel = "Search",
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
onFiltersClick,
resultsShowing,
resultsTotal,
onClear,
className,
}: FilterBarProps) {
return (
<div className={cn("rounded-xl border bg-card p-4", className)}>
<div className="flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<div className="flex min-w-0 flex-col gap-1.5 md:min-w-56 md:flex-[2]">
<Label htmlFor="filter-bar-search">{searchLabel}</Label>
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="filter-bar-search"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-9 pl-8"
/>
</div>
</div>
{filters.map((filter) => (
<div key={filter.key} className="flex min-w-0 flex-col gap-1.5 md:min-w-40 md:flex-1">
<Label>{filter.label}</Label>
<Select
items={filter.options}
value={filter.value}
onValueChange={(v) => filter.onChange(v ?? "")}
>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder={filter.placeholder} />
</SelectTrigger>
<SelectContent>
{filter.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
{onFiltersClick && (
<Button variant="outline" size="lg" className="h-9 md:w-auto" onClick={onFiltersClick}>
<FilterIcon />
Filters
</Button>
)}
</div>
{(resultsShowing !== undefined || onClear) && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-sm">
<p className="text-muted-foreground">
Showing <span className="font-medium text-foreground">{resultsShowing}</span> of{" "}
<span className="font-medium text-foreground">{resultsTotal}</span> results
</p>
{onClear && (
<button
type="button"
onClick={onClear}
className="font-medium text-primary hover:underline"
>
Clear Filters
</button>
)}
</div>
)}
</div>
)
}
export { FilterBar }
export type { FilterSelectConfig }
@@ -0,0 +1,154 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { SearchIcon } from "lucide-react"
import { getMembers } from "@/lib/api/customers"
import type { MemberSubscription } from "@/lib/models/customer"
import { useFetch } from "@/lib/use-fetch"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Input } from "@/components/ui/input"
function initialsFor(name: string | null | undefined) {
return (name ?? "?").slice(0, 2).toUpperCase()
}
function normalizePhone(value: string) {
return value.replace(/[^\d]/g, "")
}
interface GlobalCustomerSearchProps {
/** Sandbox demo mode: no backend to query, so the search is disabled. */
demo?: boolean
}
function GlobalCustomerSearch({ demo = false }: GlobalCustomerSearchProps) {
const router = useRouter()
const members = useFetch(demo ? async () => [] as MemberSubscription[] : getMembers)
const containerRef = React.useRef<HTMLDivElement>(null)
const [query, setQuery] = React.useState("")
const [open, setOpen] = React.useState(false)
const [activeIndex, setActiveIndex] = React.useState(0)
React.useEffect(() => {
function onPointerDown(e: PointerEvent) {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [])
// The member list has one row per subscription -- collapse to one entry per customer
// so the same person doesn't show up twice in the suggestions.
const customers = React.useMemo(() => {
const seen = new Map<string, MemberSubscription>()
for (const row of members.data ?? []) {
if (!seen.has(row.fkCustomerId)) seen.set(row.fkCustomerId, row)
}
return Array.from(seen.values())
}, [members.data])
const results = React.useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return []
const qDigits = normalizePhone(q)
return customers
.filter((row) => {
const name = row.customer?.cusName?.toLowerCase() ?? ""
const phone = normalizePhone(row.customer?.primaryPH ?? "")
const secondaryPhone = normalizePhone(row.customer?.secondaryPH ?? "")
return (
name.includes(q) ||
(qDigits.length > 0 && (phone.includes(qDigits) || secondaryPhone.includes(qDigits)))
)
})
.slice(0, 8)
}, [customers, query])
function selectCustomer(customerId: string) {
setOpen(false)
setQuery("")
router.push(`/dashboard/members?customerId=${customerId}`)
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
setOpen(false)
return
}
if (!open || results.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => (i + 1) % results.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => (i - 1 + results.length) % results.length)
} else if (e.key === "Enter") {
e.preventDefault()
const index = Math.min(activeIndex, results.length - 1)
selectCustomer(results[index].fkCustomerId)
}
}
if (demo) return null
const showDropdown = open && query.trim().length > 0
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder="Search members by name or phone..."
className="h-9 rounded-full pl-9"
/>
{showDropdown && (
<div className="absolute top-full left-0 z-50 mt-1.5 w-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md">
{results.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No members found.</p>
) : (
<ul className="max-h-80 overflow-y-auto py-1">
{results.map((row, index) => (
<li key={row.fkCustomerId}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectCustomer(row.fkCustomerId)}
onMouseEnter={() => setActiveIndex(index)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm ${
index === activeIndex ? "bg-muted" : ""
}`}
>
<Avatar className="size-7 shrink-0">
<AvatarFallback className="bg-primary/10 text-[0.65rem] font-semibold text-primary">
{initialsFor(row.customer?.cusName)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col leading-tight">
<span className="truncate font-medium">{row.customer?.cusName ?? "Unknown"}</span>
<span className="truncate text-xs text-muted-foreground">
{row.customer?.primaryPH ?? "No phone number"}
</span>
</div>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
export { GlobalCustomerSearch }
+25
View File
@@ -0,0 +1,25 @@
import { InfoIcon } from "lucide-react"
import { cn } from "@/lib/utils"
interface InfoBannerProps {
children: React.ReactNode
className?: string
}
function InfoBanner({ children, className }: InfoBannerProps) {
return (
<div
role="status"
className={cn(
"flex items-start gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3.5 py-3 text-sm text-blue-800 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300",
className
)}
>
<InfoIcon className="mt-0.5 size-4 shrink-0" />
<p className="text-balance">{children}</p>
</div>
)
}
export { InfoBanner }
+358
View File
@@ -0,0 +1,358 @@
import * as React from "react"
interface InvoiceAddress {
name: string
address: string[]
phone: string
email: string
}
interface InvoicePaymentRow {
label: string
amount: number
}
interface InvoicePayment {
subtotal: number
/**
* Empty when the plan is not split into installments.
* If populated, split-payment rows will be shown.
*/
payments: InvoicePaymentRow[]
totalPaid: number
balanceDue: number
}
interface GymInvoiceProps {
invoiceNo: string
invoiceDate: string
dueDate: string
/** Retained for API compatibility; the invoice no longer renders a logo. */
logoSrc?: string
logoAlt?: string
from: InvoiceAddress
billTo: InvoiceAddress
plan: {
gymPlanName: string
type: string
memberName: string
validityPeriod: string
}
payment: InvoicePayment
paymentMethod: string
preparedBy: string
notes: string[]
footerItems: string[]
className?: string
formatAmount: (amount: number) => string
/**
* This ref now captures the full A4 invoice page.
* The PDF download uses this exact node.
*/
bodyRef?: React.Ref<HTMLDivElement>
/**
* Kept for compatibility with existing caller.
* The footer is still inside the same invoice page.
*/
footerRef?: React.Ref<HTMLDivElement>
}
function GymInvoice({
invoiceNo,
invoiceDate,
dueDate,
from,
billTo,
plan,
payment,
paymentMethod,
preparedBy,
notes,
footerItems,
formatAmount,
bodyRef,
footerRef,
className = "",
}: GymInvoiceProps) {
return (
<div className={`mx-auto flex flex-col overflow-hidden bg-[#f4f4f4] px-6 py-6 font-sans ${className}`}>
<div
className="
mx-auto
h-[calc(1123px*var(--invoice-preview-scale))]
w-[calc(794px*var(--invoice-preview-scale))]
[--invoice-preview-scale:0.46]
sm:[--invoice-preview-scale:0.62]
md:[--invoice-preview-scale:1]
"
>
<div
className="
origin-top-left
transform-[scale(var(--invoice-preview-scale))]
"
>
<div
ref={bodyRef}
className="mx-auto flex h-280.75 w-198.5 flex-col overflow-hidden bg-white px-13 pt-10 pb-8 text-[#111111] font-sans"
>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<h1 className="text-[40px] leading-none font-extrabold tracking-[2px] uppercase">
Invoice
</h1>
</div>
{/* Invoice meta */}
<div className="mt-7 grid gap-3">
<MetaRow label="Invoice No." value={invoiceNo} />
<MetaRow label="Invoice Date" value={invoiceDate} />
<MetaRow label="Due Date" value={dueDate} />
</div>
<Divider className="mt-7" />
{/* From / Bill To */}
<div className="mt-8 grid grid-cols-2 gap-16">
<InfoBlock title="From" data={from} />
<InfoBlock title="Bill To" data={billTo} />
</div>
{/* Membership / Service Details */}
<div className="mt-8">
<SectionTitle title="Membership / Service Details" />
<div className="mt-6 border-t-2 border-b border-t-[#111111] border-b-[#d1d1d1]">
<div className="grid grid-cols-[30%_18%_26%_26%] border-b-[1.5px] border-b-[#111111] [&>div:not(:last-child)]:border-r [&>div:not(:last-child)]:border-r-[#d8d8d8]">
<TableHeadCell>Gym Plan Name</TableHeadCell>
<TableHeadCell>Type</TableHeadCell>
<TableHeadCell>Name of Member(s)</TableHeadCell>
<TableHeadCell>Expiry Date / Validity Period</TableHeadCell>
</div>
<div className="grid grid-cols-[30%_18%_26%_26%] [&>div:not(:last-child)]:border-r [&>div:not(:last-child)]:border-r-[#d8d8d8]">
<TableCell>{plan.gymPlanName}</TableCell>
<TableCell>{plan.type}</TableCell>
<TableCell>{plan.memberName}</TableCell>
<TableCell>{plan.validityPeriod}</TableCell>
</div>
</div>
</div>
{/* Notes / Terms + Payment Summary */}
<div className="mt-8 grid grid-cols-[1fr_300px] items-start gap-10">
<div>
<SectionTitle title="Notes / Terms" />
<div className="mt-6 [&>p+p]:mt-4 [&>p:last-child]:border-b-0 [&>p:last-child]:pb-0">
{notes.map((note, i) => (
<p
key={i}
className="m-0 border-b border-b-[#d1d1d1] pb-3 text-[13px] leading-[1.45]"
>
{note}
</p>
))}
</div>
</div>
<PaymentSummary payment={payment} formatAmount={formatAmount} />
</div>
{/* Payment Method / Prepared By */}
<div className="mt-8 grid grid-cols-2 gap-16">
<div>
<SectionTitle title="Payment Method" />
<p className="mt-5 text-[14px]">{paymentMethod || "-"}</p>
</div>
<div>
<SectionTitle title="Prepared By" />
<p className="mt-5 text-[14px]">{preparedBy || "-"}</p>
</div>
</div>
{/* Footer stays inside the same one-page A4 receipt */}
<div ref={footerRef} className="mt-auto bg-white pt-6">
<Divider />
<div className="mt-5 flex flex-wrap items-center justify-center gap-5 text-[13px] text-[#555555]">
{footerItems.map((item, i) => (
<span key={i} className="flex items-center gap-5">
{i > 0 && <span aria-hidden>|</span>}
{item}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
function Divider({ className = "" }: { className?: string }) {
return (
<div
className={`h-1.5 border-t-2 border-b-[3px] border-t-[#111111] border-b-[#8e1821] ${className}`}
/>
)
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[130px_16px_1fr] items-center gap-x-3">
<span className="text-[13px] font-extrabold uppercase">{label}</span>
<span className="text-[13px]">:</span>
<p className="m-0 text-[14px]">{value}</p>
</div>
)
}
function SectionTitle({ title }: { title: string }) {
return (
<div>
<h2 className="m-0 text-[16px] font-extrabold tracking-[0.2px]">
{title}
</h2>
<span className="mt-1 block h-0.75 w-12 bg-[#8e1821]" />
</div>
)
}
function InfoBlock({ title, data }: { title: string; data: InvoiceAddress }) {
return (
<div className="min-w-0">
<SectionTitle title={title} />
<div className="mt-6">
<h3 className="m-0 mb-1.75 text-[14px] font-extrabold">
{data.name}
</h3>
{data.address.map((line, i) => (
<p
key={i}
className="m-0 mb-1.75 wrap-break-word text-[14px] leading-tight"
>
{line}
</p>
))}
<div className="mt-5">
<p className="m-0 mb-1.75 text-[14px] leading-tight">
{data.phone}
</p>
<p className="m-0 mb-1.75 wrap-break-word text-[14px] leading-tight">
{data.email}
</p>
</div>
</div>
</div>
)
}
function TableHeadCell({ children }: { children: React.ReactNode }) {
return (
<div className="box-border flex min-h-14 items-center justify-center px-3 py-3.5 text-center text-[12px] leading-tight font-extrabold">
{children}
</div>
)
}
function TableCell({ children }: { children: React.ReactNode }) {
return (
<div className="box-border flex min-h-13.5 items-center justify-center wrap-break-word px-3 py-3.5 text-center text-[13px] leading-[1.35]">
{children}
</div>
)
}
function PaymentSummary({
payment,
formatAmount,
}: {
payment: InvoicePayment
formatAmount: (amount: number) => string
}) {
const hasBreakdown = payment.payments.length > 0
return (
<div className="border border-[#111111]">
<SummaryRow label="Subtotal" amount={formatAmount(payment.subtotal)} />
{hasBreakdown ? (
<>
{payment.payments.map((row, i) => (
<SummaryRow
key={i}
label={row.label}
amount={formatAmount(row.amount)}
/>
))}
<SummaryRow
label="Total Paid"
amount={formatAmount(payment.totalPaid)}
/>
<SummaryRow
label="Balance Due"
amount={formatAmount(payment.balanceDue)}
highlight
/>
</>
) : (
<SummaryRow
label="Total"
amount={formatAmount(payment.subtotal)}
highlight
/>
)}
</div>
)
}
function SummaryRow({
label,
amount,
highlight = false,
}: {
label: string
amount: string
highlight?: boolean
}) {
return (
<div
className={`grid min-h-12 grid-cols-[1fr_auto] items-center gap-5 border-b border-b-[#d1d1d1] px-5 last:border-b-0 ${highlight
? "border-t-[1.5px] border-t-[#111111] bg-[#f7f7f7]"
: ""
}`}
>
<span
className={`font-extrabold uppercase ${highlight ? "text-[15px] font-black" : "text-[13px]"
}`}
>
{label}
</span>
<strong
className={`font-bold ${highlight ? "text-[15px] font-black" : "text-[13px]"
}`}
>
{amount}
</strong>
</div>
)
}
export { GymInvoice }
export type {
GymInvoiceProps,
InvoiceAddress,
InvoicePayment,
InvoicePaymentRow,
}
@@ -0,0 +1,82 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import type { MonthlyRevenueStat } from "@/lib/models/stats"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
const chartConfig = {
newMemberCount: {
label: "New members",
color: "var(--primary)",
},
} satisfies ChartConfig
interface MemberTrendChartCardProps {
data: MonthlyRevenueStat[] | null
loading?: boolean
title?: string
}
/** Non-revenue analog of RevenueChartCard, for staff who can't see revenue figures. */
function MemberTrendChartCard({ data, loading, title = "New members" }: MemberTrendChartCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-56 w-full rounded-lg" />
) : !data || data.length === 0 ? (
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
No new members recorded yet.
</div>
) : (
<ChartContainer config={chartConfig} className="h-56 w-full">
<LineChart data={data} margin={{ left: 4, right: 4, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="monthLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
tick={{ fontSize: 11 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={32}
allowDecimals={false}
tick={{ fontSize: 11 }}
/>
<ChartTooltip
cursor={{ stroke: "var(--border)", strokeWidth: 1 }}
content={<ChartTooltipContent formatter={(value) => String(value)} />}
/>
<Line
type="monotone"
dataKey="newMemberCount"
stroke="var(--color-newMemberCount)"
strokeWidth={2}
dot={{ r: 3, fill: "var(--color-newMemberCount)", strokeWidth: 0 }}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--background)" }}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
export { MemberTrendChartCard }
+148
View File
@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { LayoutGridIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { MOBILE_PRIMARY_KEYS, NAV_ITEMS, isNavItemActive, type DashboardNavItem } from "@/lib/nav"
import { useNavItems } from "@/hooks/use-nav-items"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
interface MobileNavProps {
/** Sandbox demo mode: navigate by key with local state instead of route links. */
activeKey?: string
onNavigate?: (key: string) => void
}
function MobileNav({ activeKey, onNavigate }: MobileNavProps) {
const pathname = usePathname()
const [moreOpen, setMoreOpen] = React.useState(false)
const { items: liveItems } = useNavItems(!onNavigate)
const allItems = onNavigate ? NAV_ITEMS : liveItems
const primary = allItems.filter((item) => MOBILE_PRIMARY_KEYS.includes(item.key))
const secondary = allItems.filter((item) => !MOBILE_PRIMARY_KEYS.includes(item.key))
const isActive = (item: DashboardNavItem) =>
onNavigate ? item.key === activeKey : isNavItemActive(item, pathname)
const moreActive = secondary.some(isActive)
return (
<nav className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur supports-backdrop-filter:bg-background/85 md:hidden">
<div className="grid grid-cols-5">
{primary.map((item) => (
<MobileNavButton
key={item.key}
item={item}
active={isActive(item)}
onNavigate={onNavigate}
/>
))}
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetTrigger
render={
<button
className={cn(
"flex flex-col items-center gap-1 py-2 text-[0.65rem] font-medium",
moreActive ? "text-primary" : "text-muted-foreground"
)}
>
<LayoutGridIcon className="size-5" />
<span>More</span>
</button>
}
/>
<SheetContent side="bottom" className="pb-[calc(env(safe-area-inset-bottom)+1rem)]">
<SheetHeader>
<SheetTitle>All sections</SheetTitle>
</SheetHeader>
<div className="grid grid-cols-3 gap-2 px-4">
{secondary.map((item) => {
const active = isActive(item)
const content = (
<>
<item.icon className="size-5" />
<span className="text-center text-xs leading-tight">{item.label}</span>
</>
)
const itemClass = cn(
"flex flex-col items-center gap-1.5 rounded-xl border p-3",
active
? "border-primary/40 bg-primary/10 text-primary"
: "bg-card text-muted-foreground"
)
return onNavigate ? (
<button
key={item.key}
className={itemClass}
onClick={() => {
setMoreOpen(false)
onNavigate(item.key)
}}
>
{content}
</button>
) : (
<Link
key={item.key}
href={item.href}
className={itemClass}
onClick={() => setMoreOpen(false)}
>
{content}
</Link>
)
})}
</div>
</SheetContent>
</Sheet>
</div>
</nav>
)
}
function MobileNavButton({
item,
active,
onNavigate,
}: {
item: DashboardNavItem
active: boolean
onNavigate?: (key: string) => void
}) {
const className = cn(
"flex flex-col items-center gap-1 py-2 text-[0.65rem] font-medium",
active ? "text-primary" : "text-muted-foreground"
)
const content = (
<>
<item.icon className="size-5" />
<span>{item.shortLabel ?? item.label}</span>
</>
)
if (onNavigate) {
return (
<button className={className} onClick={() => onNavigate(item.key)}>
{content}
</button>
)
}
return (
<Link href={item.href} className={className}>
{content}
</Link>
)
}
export { MobileNav }
+24
View File
@@ -0,0 +1,24 @@
import { cn } from "@/lib/utils"
interface PageHeaderProps {
title: string
description?: string
action?: React.ReactNode
className?: string
}
function PageHeader({ title, description, action, className }: PageHeaderProps) {
return (
<div className={cn("flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", className)}>
<div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)
}
export { PageHeader }
+57
View File
@@ -0,0 +1,57 @@
import { Skeleton } from "@/components/ui/skeleton"
type PageSkeletonVariant = "stats-table" | "stats-cards" | "chart-list"
interface PageSkeletonProps {
variant?: PageSkeletonVariant
}
function PageSkeleton({ variant = "stats-table" }: PageSkeletonProps) {
return (
<div className="flex flex-col gap-4">
<div className="space-y-2">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-4 w-64 max-w-full" />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
{variant === "stats-table" && <TableSkeleton />}
{variant === "stats-cards" && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-44 rounded-xl" />
))}
</div>
)}
{variant === "chart-list" && (
<>
<Skeleton className="h-64 rounded-xl" />
<TableSkeleton rows={3} />
</>
)}
</div>
)
}
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-9 w-full max-w-56" />
<Skeleton className="h-9 w-24" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</div>
)
}
export { PageSkeleton, TableSkeleton }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface PaginationBarProps {
page: number
pageCount: number
pageSize: number
onPageSizeChange: (size: number) => void
rangeStart: number
rangeEnd: number
total: number
onPageChange: (page: number) => void
pageSizeOptions?: number[]
}
function PaginationBar({
page,
pageCount,
pageSize,
onPageSizeChange,
rangeStart,
rangeEnd,
total,
onPageChange,
pageSizeOptions = [10, 25, 50, 100],
}: PaginationBarProps) {
const pages = Array.from({ length: Math.max(pageCount, 1) }, (_, i) => i + 1)
return (
<div className="flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Show</span>
<Select
items={pageSizeOptions.map((size) => ({ label: String(size), value: String(size) }))}
value={String(pageSize)}
onValueChange={(v) => v && onPageSizeChange(Number(v))}
>
<SelectTrigger size="sm" className="w-[4.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span>entries</span>
</div>
<div className="flex items-center gap-3">
<p className="hidden text-sm text-muted-foreground sm:block">
{rangeStart} - {rangeEnd} of {total}
</p>
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
text=""
aria-disabled={page <= 1}
className="pointer-events-auto"
onClick={(e) => {
e.preventDefault()
if (page > 1) onPageChange(page - 1)
}}
href="#"
/>
</PaginationItem>
{pages.map((p) => (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => {
e.preventDefault()
onPageChange(p)
}}
>
{p}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
text=""
onClick={(e) => {
e.preventDefault()
if (page < pageCount) onPageChange(page + 1)
}}
href="#"
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)
}
export { PaginationBar }
+139
View File
@@ -0,0 +1,139 @@
import { EyeIcon, HeartIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { StatusBadge } from "@/components/hexa/status-badge"
interface PostCardAuthor {
name: string
role?: string
avatarUrl?: string
}
interface PostCardProps {
imageUrl?: string
title: string
description: string
tag?: string
author: PostCardAuthor
published: boolean
likes: number
createdAt: string
liked?: boolean
onToggleLike?: () => void
onView?: () => void
onEdit?: () => void
onDelete?: () => void
className?: string
}
function PostCard({
imageUrl,
title,
description,
tag,
author,
published,
likes,
createdAt,
liked,
onToggleLike,
onView,
onEdit,
onDelete,
className,
}: PostCardProps) {
return (
<Card className={cn("gap-0 overflow-hidden p-0", className)}>
{imageUrl && (
<div className="aspect-video w-full overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt="" className="size-full object-cover" />
</div>
)}
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold">{title}</h3>
<StatusBadge status={published ? "success" : "neutral"}>
{published ? "Published" : "Draft"}
</StatusBadge>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">{description}</p>
{tag && (
<Badge variant="secondary" className="w-fit">
{tag}
</Badge>
)}
<div className="flex items-center justify-between gap-2 border-t pt-3">
<div className="flex min-w-0 items-center gap-2">
<Avatar size="sm">
<AvatarImage src={author.avatarUrl} alt={author.name} />
<AvatarFallback>{author.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="min-w-0 leading-tight">
<p className="truncate text-sm font-medium">{author.name}</p>
{author.role && (
<p className="truncate text-xs text-muted-foreground">{author.role}</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
{onToggleLike ? (
<button
type="button"
aria-label={liked ? "Unlike post" : "Like post"}
className="flex items-center gap-1"
onClick={onToggleLike}
>
<HeartIcon
className={cn(
"size-3.5",
liked ? "fill-rose-500 text-rose-500" : "text-muted-foreground"
)}
/>
{likes}
</button>
) : (
<span className="flex items-center gap-1">
<HeartIcon className="size-3.5 fill-rose-500 text-rose-500" />
{likes}
</span>
)}
<span>{createdAt}</span>
</div>
</div>
{(onView || onEdit || onDelete) && (
<div className="flex items-center justify-end gap-1.5">
{onView && (
<Button variant="outline" size="icon-sm" aria-label="View" onClick={onView}>
<EyeIcon />
</Button>
)}
{onEdit && (
<Button variant="outline" size="icon-sm" aria-label="Edit" onClick={onEdit}>
<PencilIcon />
</Button>
)}
{onDelete && (
<Button variant="destructive" size="icon-sm" aria-label="Delete" onClick={onDelete}>
<Trash2Icon />
</Button>
)}
</div>
)}
</div>
</Card>
)
}
export { PostCard }
export type { PostCardAuthor }
+37
View File
@@ -0,0 +1,37 @@
"use client"
import * as React from "react"
import { RefreshCwIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface RefreshButtonProps {
onRefresh?: () => void | Promise<void>
className?: string
}
function RefreshButton({ onRefresh, className }: RefreshButtonProps) {
const [spinning, setSpinning] = React.useState(false)
async function handleClick() {
if (spinning) return
setSpinning(true)
await onRefresh?.()
window.setTimeout(() => setSpinning(false), 500)
}
return (
<Button
variant="outline"
size="icon"
className={cn("rounded-full", className)}
onClick={handleClick}
aria-label="Refresh"
>
<RefreshCwIcon className={cn(spinning && "animate-spin")} />
</Button>
)
}
export { RefreshButton }
@@ -0,0 +1,91 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { formatCurrency } from "@/lib/format"
import type { MonthlyRevenueStat } from "@/lib/models/stats"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
const chartConfig = {
revenue: {
label: "Revenue",
color: "var(--primary)",
},
} satisfies ChartConfig
function formatCompactRevenue(value: number) {
if (Math.abs(value) >= 1000) return `Rs. ${(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })}k`
return `Rs. ${value.toLocaleString()}`
}
interface RevenueChartCardProps {
data: MonthlyRevenueStat[] | null
loading?: boolean
title?: string
}
function RevenueChartCard({ data, loading, title = "Monthly revenue" }: RevenueChartCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-56 w-full rounded-lg" />
) : !data || data.length === 0 ? (
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
No revenue recorded yet.
</div>
) : (
<ChartContainer config={chartConfig} className="h-56 w-full">
<LineChart data={data} margin={{ left: 4, right: 4, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="monthLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
tick={{ fontSize: 11 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={56}
tick={{ fontSize: 11 }}
tickFormatter={(value) => formatCompactRevenue(Number(value))}
/>
<ChartTooltip
cursor={{ stroke: "var(--border)", strokeWidth: 1 }}
content={
<ChartTooltipContent
formatter={(value) => formatCurrency(Number(value))}
/>
}
/>
<Line
type="monotone"
dataKey="revenue"
stroke="var(--color-revenue)"
strokeWidth={2}
dot={{ r: 3, fill: "var(--color-revenue)", strokeWidth: 0 }}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--background)" }}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
export { RevenueChartCard }
+121
View File
@@ -0,0 +1,121 @@
"use client"
import { driver, type Driver, type DriveStep } from "driver.js"
import "driver.js/dist/driver.css"
interface TourStepConfig extends DriveStep {
key: string
}
const WHATSAPP_NUMBER = "94770000000" // placeholder — replace with a real business number before ship
function buildSteps(): TourStepConfig[] {
return [
{
key: "dashboard",
element: '[data-tour="dashboard"]',
popover: {
title: "Your gym at a glance",
description: "This is your dashboard with revenue, members, and check-ins, all in one place.",
side: "bottom",
},
},
{
key: "attendance",
element: '[data-tour="nav-attendance"]',
popover: {
title: "Mark a check-in",
description: "Tap any member to check them in. No door device is required to get started.",
side: "right",
},
},
{
key: "members",
element: '[data-tour="nav-members"]',
popover: {
title: "Member profiles",
description: "Manage all your gym members from one place—track memberships, plans, and activity.",
side: "right",
},
},
{
key: "payments",
element: '[data-tour="nav-payments"]',
popover: {
title: "Track who's due",
description: "See who's due for payment. Payment reminders are sent automatically via WhatsApp",
side: "right",
},
},
{
key: "invoice",
element: '[data-tour="nav-payments"]',
popover: {
title: "Professional invoices",
description: "Receipts are generated automatically—no spreadsheets needed.",
side: "right",
},
},
{
key: "member-app-preview",
element: '[data-tour="member-app-preview"]',
popover: {
title: "Your members get an app too",
description: "Workouts, meal plans, and QR check-ins—all in your members' pockets",
side: "bottom",
},
},
{
key: "finale",
popover: {
title: "Imagine this with your gym's name on it",
description: `We'll call you shortly or reach out now. <a href="https://wa.me/${WHATSAPP_NUMBER}" target="_blank" rel="noopener noreferrer" style="color: var(--primary); font-weight: 600; text-decoration: underline;">Chat with us on WhatsApp</a>`,
},
},
]
}
interface StartSandboxTourOptions {
onStepComplete: (stepKey: string) => void
}
function startSandboxTour({ onStepComplete }: StartSandboxTourOptions): Driver {
const steps = buildSteps()
let lastReportedKey: string | null = null
function reportOnce(stepKey: string) {
if (lastReportedKey === stepKey) return
lastReportedKey = stepKey
onStepComplete(stepKey)
}
const tourDriver = driver({
showProgress: true,
allowClose: true,
steps: steps.map((step) => ({
element: step.element,
popover: {
...step.popover,
onNextClick: () => {
reportOnce(step.key)
tourDriver.moveNext()
},
onDoneClick: () => {
reportOnce(step.key)
tourDriver.destroy()
},
},
})),
onDestroyStarted: () => {
const activeStep = tourDriver.getActiveStep()
const activeKey = steps.find((s) => s.element === activeStep?.element)?.key
if (activeKey) reportOnce(activeKey)
tourDriver.destroy()
},
})
tourDriver.drive()
return tourDriver
}
export { startSandboxTour }
+78
View File
@@ -0,0 +1,78 @@
"use client"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
interface SelectableCardOption {
value: string
label: string
description?: string
icon?: LucideIcon
}
interface SelectableCardGroupProps {
options: SelectableCardOption[]
value: string
onValueChange: (value: string) => void
className?: string
columns?: 1 | 2 | 3
disabled?: boolean
}
function SelectableCardGroup({
options,
value,
onValueChange,
className,
columns = 2,
disabled = false,
}: SelectableCardGroupProps) {
return (
<RadioGroup
value={value}
onValueChange={(v) => onValueChange(String(v))}
disabled={disabled}
className={cn(
"grid gap-3",
columns === 1 && "grid-cols-1",
columns === 2 && "grid-cols-1 sm:grid-cols-2",
columns === 3 && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
className
)}
>
{options.map((opt) => {
const Icon = opt.icon
return (
<label
key={opt.value}
htmlFor={`selectable-card-${opt.value}`}
className={cn(
"flex items-start gap-3 rounded-xl border bg-card p-4 transition-colors has-data-checked:border-primary has-data-checked:bg-primary/5 has-data-checked:ring-1 has-data-checked:ring-primary/30",
disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer"
)}
>
<RadioGroupItem
id={`selectable-card-${opt.value}`}
value={opt.value}
className="mt-1"
/>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2 font-medium">
{Icon && <Icon className="size-4 text-primary" />}
{opt.label}
</div>
{opt.description && (
<p className="text-sm text-muted-foreground">{opt.description}</p>
)}
</div>
</label>
)
})}
</RadioGroup>
)
}
export { SelectableCardGroup }
export type { SelectableCardOption }
+165
View File
@@ -0,0 +1,165 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import {
BellIcon,
LogOutIcon,
SettingsIcon,
UserIcon,
} from "lucide-react"
import { logout } from "@/lib/api/session"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { GlobalCustomerSearch } from "@/components/hexa/global-customer-search"
import { RefreshButton } from "@/components/hexa/refresh-button"
import { ThemeToggle } from "@/components/hexa/theme-toggle"
import { useClientProfile } from "@/components/hexa/client-profile-context"
interface SiteHeaderProps {
date: Date | null
onDateChange: (date: Date) => void
notificationCount?: number
onRefresh?: () => void
demo?: boolean
onNavigate?: (key: string) => void
}
function SiteHeader({
date,
notificationCount = 0,
onRefresh,
demo = false,
onNavigate,
}: SiteHeaderProps) {
const fullDate = date?.toLocaleDateString("en-LK", { month: "long", day: "numeric", year: "numeric", timeZone: "Asia/Colombo" }) ?? "Today"
const compactDate = date?.toLocaleDateString("en-LK", { month: "short", day: "numeric", timeZone: "Asia/Colombo" }) ?? "Today"
const router = useRouter()
const profile = useClientProfile()
async function handleLogout() {
if (demo) {
router.push("/login")
return
}
await logout()
router.replace("/login")
}
async function navigateToProfile() {
if (onNavigate) {
onNavigate("settings")
return
}
router.push("/dashboard/settings")
}
const displayName = demo ? "Demo Gym" : profile.clientName ?? profile.gymName ?? "My Gym"
const initials =
displayName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("") || "G"
return (
<header className="sticky top-0 z-30 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 sm:px-4">
<SidebarTrigger className="rounded-full" />
<div className="ml-1 flex h-9 shrink-0 items-center rounded-full border bg-muted/30 px-3 text-sm" aria-label="Today's date">
<span className="hidden sm:inline">{fullDate}</span>
<span className="sm:hidden">{compactDate}</span>
</div>
<div className="flex flex-1 justify-center px-2">
<GlobalCustomerSearch demo={demo} />
</div>
<div className="ml-auto flex items-center gap-2">
<RefreshButton onRefresh={onRefresh} />
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" className="relative rounded-full">
<BellIcon />
{notificationCount > 0 && (
<Badge className="absolute -top-1.5 -right-1.5 size-5 justify-center rounded-full p-0 text-[0.65rem]">
{notificationCount}
</Badge>
)}
</Button>
}
/>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex-col items-start gap-0.5">
<span className="font-medium">New membership payment received</span>
<span className="text-xs text-muted-foreground">2 minutes ago</span>
</DropdownMenuItem>
<DropdownMenuItem className="flex-col items-start gap-0.5">
<span className="font-medium">Staff member added</span>
<span className="text-xs text-muted-foreground">1 hour ago</span>
</DropdownMenuItem>
<DropdownMenuItem className="flex-col items-start gap-0.5">
<span className="font-medium">Gym plan updated</span>
<span className="text-xs text-muted-foreground">Yesterday</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button aria-label="Profile menu">
<Avatar>
<AvatarFallback className="bg-primary text-primary-foreground">
{initials}
</AvatarFallback>
</Avatar>
</button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>{displayName}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={navigateToProfile}>
<UserIcon />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOutIcon />
{demo ? "Exit demo" : "Log out"}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
export { SiteHeader }
+45
View File
@@ -0,0 +1,45 @@
import { cva, type VariantProps } from "class-variance-authority"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
const iconVariants = cva("flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4 sm:size-10 sm:rounded-xl sm:[&_svg]:size-5", {
variants: {
tone: {
primary: "bg-primary/10 text-primary",
blue: "bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400",
green: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400",
amber: "bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400",
purple: "bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400",
},
},
defaultVariants: {
tone: "primary",
},
})
interface StatCardProps extends VariantProps<typeof iconVariants> {
icon: LucideIcon
label: string
value: React.ReactNode
hint?: string
className?: string
}
function StatCard({ icon: Icon, label, value, hint, tone, className }: StatCardProps) {
return (
<Card className={cn("flex-row items-center gap-2.5 p-2.5 sm:gap-3 sm:p-3", className)}>
<div className={cn(iconVariants({ tone }))}>
<Icon />
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-xs text-muted-foreground sm:text-sm">{label}</span>
<span className="text-base leading-tight font-semibold tracking-tight sm:text-xl">{value}</span>
{hint && <span className="truncate text-xs text-muted-foreground">{hint}</span>}
</div>
</Card>
)
}
export { StatCard }
+53
View File
@@ -0,0 +1,53 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const statusBadgeVariants = cva(
"inline-flex h-6 w-fit shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
{
variants: {
status: {
success: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400",
warning: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400",
danger: "border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400",
info: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-400",
neutral: "border-border bg-muted text-muted-foreground",
},
},
defaultVariants: {
status: "neutral",
},
}
)
const dotVariants = cva("size-1.5 shrink-0 rounded-full", {
variants: {
status: {
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-red-500",
info: "bg-blue-500",
neutral: "bg-muted-foreground",
},
},
defaultVariants: {
status: "neutral",
},
})
interface StatusBadgeProps
extends React.ComponentProps<"span">,
VariantProps<typeof statusBadgeVariants> {
dot?: boolean
}
function StatusBadge({ className, status, dot = true, children, ...props }: StatusBadgeProps) {
return (
<span className={cn(statusBadgeVariants({ status }), className)} {...props}>
{dot && <span className={cn(dotVariants({ status }))} />}
{children}
</span>
)
}
export { StatusBadge, statusBadgeVariants }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { MoonIcon, SunIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time client-mount flag to avoid SSR/client theme mismatch
setMounted(true)
}, [])
return (
<Button
variant="outline"
size="icon"
className={cn("rounded-full", className)}
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{mounted && resolvedTheme === "dark" ? <SunIcon /> : <MoonIcon />}
</Button>
)
}
export { ThemeToggle }
@@ -0,0 +1,102 @@
"use client"
import * as React from "react"
import { useMutation } from "@/lib/use-mutation"
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 { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
interface AddCategoryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
kind: "workout" | "meal"
onCreate: (name: string, description?: string) => Promise<void>
}
function AddCategoryDialog({ open, onOpenChange, kind, onCreate }: AddCategoryDialogProps) {
const [name, setName] = React.useState("")
const [description, setDescription] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setName("")
setDescription("")
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return
try {
await run(() => onCreate(name.trim(), description.trim() || undefined))
onOpenChange(false)
} catch {
// run() already toasted the real error; stay open so the user can 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>New {kind === "workout" ? "workout" : "meal"} category</DialogTitle>
<DialogDescription>
Group your {kind === "workout" ? "workout" : "meal"} plans by category.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="category-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="category-name"
placeholder={kind === "workout" ? "e.g. Strength, Cardio" : "e.g. Weight loss, Bulking"}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="category-desc">Description</Label>
<Textarea
id="category-desc"
placeholder="Optional short description"
maxLength={200}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || busy}>
{busy && <Spinner className="size-4" />}
Create Category
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { AddCategoryDialog }
@@ -0,0 +1,304 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { createMember, getMembers } from "@/lib/api/customers"
import { addSubscriptionMember, getSubscription } from "@/lib/api/subscriptions"
import { useMediaQuery } from "@/lib/use-media-query"
import { useMutation } from "@/lib/use-mutation"
import type { SubscriptionSummary } from "@/lib/models/subscription"
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 {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface AddGroupMembersDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
summary: SubscriptionSummary | null
/** Called after a member is added so the caller can refresh. When a member was actually
* attached (not the demo path), this also carries the freshly refetched subscription so
* the caller can update its own copy (e.g. groupSummary) — without this, a caller that
* reopens the dialog later would show a stale member count. */
onChanged: (freshSummary?: SubscriptionSummary) => void
/** Sandbox demo mode: mutate local state instead of calling the backend. */
demo?: boolean
/** Pre-computed eligible-members list for the "existing member" tab (parent owns the customer roster). */
demoEligibleOptions?: { label: string; value: string }[]
demoAttachExisting?: (subscriptionId: string, customerId: string) => void
demoCreateAndAttach?: (subscriptionId: string, values: typeof EMPTY_NEW) => void
}
const EMPTY_NEW = {
fullname: "",
userName: "",
email: "",
phoneNumber: "",
gender: "",
birthday: "",
}
function AddGroupMembersDialog({
open,
onOpenChange,
summary,
onChanged,
demo = false,
demoEligibleOptions,
demoAttachExisting,
demoCreateAndAttach,
}: AddGroupMembersDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [existingOptions, setExistingOptions] = React.useState<{ label: string; value: string }[]>([])
const [selectedExisting, setSelectedExisting] = React.useState("")
const [newValues, setNewValues] = React.useState(EMPTY_NEW)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
// Cosmetic only ("Done" vs "Cancel") -- the actual count always comes straight from
// `summary`, which is kept in sync with the server after every add (see afterAdd below).
const [hasAdded, setHasAdded] = React.useState(false)
const memberCount = summary?.members.length ?? 0
const limit = summary?.memberLimit ?? 0
const atLimit = memberCount >= limit
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSelectedExisting("")
setNewValues(EMPTY_NEW)
}
}
// The dialog now stays open across multiple adds (a Group plan's members are usually added
// one at a time right after creation), so the "existing member" list needs to re-sync
// whenever the live member count changes, not just once when the dialog first opens.
React.useEffect(() => {
if (!open) return
if (demo) {
setExistingOptions(demoEligibleOptions ?? [])
} else {
void loadEligible(summary)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, memberCount, demo, summary])
async function loadEligible(current: SubscriptionSummary | null) {
if (!current) return
try {
const members = await getMembers()
const alreadyIn = new Set(current.members.map((m) => m.customerId))
// Dedupe by customer; exclude those already in this subscription.
const seen = new Set<string>()
const options: { label: string; value: string }[] = []
for (const row of members) {
const id = row.fkCustomerId
if (alreadyIn.has(id) || seen.has(id) || !row.customer) continue
seen.add(id)
options.push({ label: row.customer.cusName ?? row.customer.email ?? "Member", value: id })
}
setExistingOptions(options)
} catch {
// Non-fatal; the create-new tab still works.
}
}
/** Re-fetches the subscription so `summary` (and the caller's own copy of it) reflects
* the member just added, then refreshes the eligible-members list against that truth.
* This is what keeps the "N/limit" count and the "full" gate correct -- a client-side
* counter can't be trusted since the caller may reopen this dialog from stale data. */
async function afterAdd() {
setSelectedExisting("")
setNewValues(EMPTY_NEW)
setHasAdded(true)
if (!summary) return
if (demo) {
onChanged()
return
}
try {
const fresh = await getSubscription(summary.subscriptionId)
onChanged(fresh)
void loadEligible(fresh)
} catch {
// Non-fatal; the count may lag until the dialog is reopened.
onChanged()
}
}
async function attachExisting() {
if (!summary || !selectedExisting || busy) return
if (demo) {
demoAttachExisting?.(summary.subscriptionId, selectedExisting)
toast.success("Member added to the plan")
setSelectedExisting("")
onChanged()
return
}
try {
await run(() => addSubscriptionMember(summary.subscriptionId, selectedExisting))
toast.success("Member added to the plan")
// Stay open instead of closing -- a Group plan usually needs several members added back
// to back, and the "plan is full" state below takes over once the limit is reached.
setSelectedExisting("")
onChanged()
} catch {
// run() already toasted the real error.
}
}
async function createAndAttach(e: React.FormEvent) {
e.preventDefault()
if (!summary || busy) return
const v = newValues
if (!v.fullname.trim() || !v.userName.trim() || !v.email.trim() || !v.phoneNumber.trim() || !v.gender) return
if (demo) {
demoCreateAndAttach?.(summary.subscriptionId, v)
toast.success("Member created and added to the plan")
setNewValues(EMPTY_NEW)
onChanged()
return
}
try {
await run(async () => {
const created = await createMember({
fullname: v.fullname.trim(),
userName: v.userName.trim(),
email: v.email.trim(),
mobileNumber: v.phoneNumber.trim(),
gender: v.gender,
birthday: v.birthday,
})
await addSubscriptionMember(summary.subscriptionId, created.customer.cusId)
})
toast.success("Member created and added to the plan")
// Stay open instead of closing -- same reasoning as attachExisting above.
setNewValues(EMPTY_NEW)
onChanged()
} catch {
// run() already toasted the real error.
}
}
function setNew<K extends keyof typeof EMPTY_NEW>(key: K, value: string) {
setNewValues((n) => ({ ...n, [key]: value }))
}
const Footer = isDesktop ? DialogFooter : SheetFooter
const footerClassName = isDesktop ? "-mx-4 -mt-3 -mb-4" : "flex-col-reverse"
const buttonWidthClassName = isDesktop ? undefined : "w-full"
const body = atLimit ? (
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-center text-sm text-muted-foreground">
This plan is full ({limit} members).
</p>
<Button type="button" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Done</Button>
</div>
) : (
<Tabs defaultValue="existing">
<TabsList className="w-full">
<TabsTrigger value="existing" className="flex-1">Existing member</TabsTrigger>
<TabsTrigger value="new" className="flex-1">New member</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Select a member</Label>
<Select items={existingOptions} value={selectedExisting || null} onValueChange={(v) => setSelectedExisting(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder={existingOptions.length ? "Choose a member" : "No eligible members"} /></SelectTrigger>
<SelectContent>{existingOptions.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>{hasAdded ? "Done" : "Cancel"}</Button>
<Button type="button" className={buttonWidthClassName} onClick={attachExisting} disabled={!selectedExisting || busy}>{busy && <Spinner className="size-4" />}Add to plan</Button>
</Footer>
</TabsContent>
<TabsContent value="new" className="mt-3">
<form onSubmit={createAndAttach} className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-fullname">Full Name <span className="text-destructive">*</span></Label><Input id="gm-fullname" value={newValues.fullname} onChange={(e) => setNew("fullname", e.target.value)} required /></div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-username">Username <span className="text-destructive">*</span></Label><Input id="gm-username" value={newValues.userName} onChange={(e) => setNew("userName", e.target.value)} required /></div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-phone">Phone <span className="text-destructive">*</span></Label><Input id="gm-phone" type="tel" value={newValues.phoneNumber} onChange={(e) => setNew("phoneNumber", e.target.value)} required /></div>
</div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-email">Email <span className="text-destructive">*</span></Label><Input id="gm-email" type="email" value={newValues.email} onChange={(e) => setNew("email", e.target.value)} required /></div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Gender <span className="text-destructive">*</span></Label>
<Select items={[{ label: "Male", value: "Male" }, { label: "Female", value: "Female" }, { label: "Other", value: "Other" }]} value={newValues.gender || null} onValueChange={(v) => setNew("gender", v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="Male">Male</SelectItem><SelectItem value="Female">Female</SelectItem><SelectItem value="Other">Other</SelectItem></SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-birthday">Birthday</Label><Input id="gm-birthday" type="date" value={newValues.birthday} onChange={(e) => setNew("birthday", e.target.value)} max={new Date().toISOString().slice(0, 10)} /></div>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>{hasAdded ? "Done" : "Cancel"}</Button>
<Button type="submit" className={buttonWidthClassName} disabled={busy}>{busy && <Spinner className="size-4" />}Create & add</Button>
</Footer>
</form>
</TabsContent>
</Tabs>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Add group members</DialogTitle>
<DialogDescription>
{summary?.planName} · {memberCount}/{limit} members
</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>Add group members</SheetTitle>
<SheetDescription>
{summary?.planName} · {memberCount}/{limit} members
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
export { AddGroupMembersDialog }
@@ -0,0 +1,456 @@
"use client"
import * as React from "react"
import { useMutation } from "@/lib/use-mutation"
import type { Place } from "@/lib/models/place"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
/** UI-level scope; "Couple"/"Family" both map to a Group plan with a member limit. */
type ScopeChoice = "Individual" | "Couple" | "Family"
type DurationMode = "Rolling" | "FixedRange"
interface GymPlanFormValues {
name: string
description: string
scope: ScopeChoice
/** Member limit for Family plans (Couple is fixed at 2). */
memberLimit: string
durationMode: DurationMode
/** Duration in days (stringified) — Rolling only. */
duration: string
/** ISO date strings — FixedRange only. */
validFrom: string
validTo: string
price: string
hasOnboardingFee: boolean
onboardingFeeAmount: string
allowSplit: boolean
installmentCount: string
/** Places this plan grants access to. Empty = unrestricted (every active device). */
placeIds: string[]
}
const EMPTY_VALUES: GymPlanFormValues = {
name: "",
description: "",
scope: "Individual",
memberLimit: "3",
durationMode: "Rolling",
duration: "",
validFrom: "",
validTo: "",
price: "",
hasOnboardingFee: false,
onboardingFeeAmount: "",
allowSplit: false,
installmentCount: "",
placeIds: [],
}
const DURATION_OPTIONS = [
{ label: "1 month (30 days)", value: "30" },
{ label: "3 months (90 days)", value: "90" },
{ label: "6 months (180 days)", value: "180" },
{ label: "12 months (365 days)", value: "365" },
{ label: "Custom…", value: "custom" },
]
const SCOPE_OPTIONS: { label: string; value: ScopeChoice }[] = [
{ label: "Individual", value: "Individual" },
{ label: "Couple (2 members)", value: "Couple" },
{ label: "Family (group)", value: "Family" },
]
interface AddGymPlanDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: GymPlanFormValues) => Promise<void> | void
mode?: "create" | "edit"
initialValues?: Partial<GymPlanFormValues>
/** Active places to choose from. Empty selection means "unrestricted". */
places?: Place[]
}
function AddGymPlanDialog({
open,
onOpenChange,
onSubmit,
mode = "create",
initialValues,
places = [],
}: AddGymPlanDialogProps) {
const [values, setValues] = React.useState<GymPlanFormValues>(EMPTY_VALUES)
const [customDuration, setCustomDuration] = React.useState(false)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
const merged = { ...EMPTY_VALUES, ...initialValues }
setValues(merged)
// A duration that isn't one of the presets means it was entered as custom.
setCustomDuration(
!!merged.duration && !DURATION_OPTIONS.some((o) => o.value === merged.duration)
)
}
}
function set<K extends keyof GymPlanFormValues>(key: K, value: GymPlanFormValues[K]) {
setValues((v) => ({ ...v, [key]: value }))
}
const isRolling = values.durationMode === "Rolling"
const durationValid = isRolling
? !!values.duration && Number(values.duration) >= 1
: !!values.validFrom && !!values.validTo && values.validTo > values.validFrom
const feeValid = !values.hasOnboardingFee || Number(values.onboardingFeeAmount) > 0
const canSubmit = !!values.name.trim() && !!values.price && durationValid && feeValid && !busy
// Installment preview: "Rs. 6,000 → 6 × Rs. 1,000".
const price = Number(values.price) || 0
const count = Math.max(1, Number(values.installmentCount) || 0)
const perInstallment = count > 0 ? Math.round((price / count) * 100) / 100 : 0
const onboardingFee = values.hasOnboardingFee ? Number(values.onboardingFeeAmount) || 0 : 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() => Promise.resolve(onSubmit(values)))
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Add Gym Plan" : "Edit Gym Plan"}</DialogTitle>
<DialogDescription>
{mode === "create"
? "Create a new membership plan for your gym."
: "Update this membership plan."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-name">
Plan Name <span className="text-destructive">*</span>
</Label>
<Input
id="plan-name"
placeholder="Enter plan name"
value={values.name}
onChange={(e) => set("name", e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-description">Description</Label>
<Textarea
id="plan-description"
placeholder="Short description of this plan"
maxLength={300}
value={values.description}
onChange={(e) => set("description", e.target.value)}
/>
</div>
{/* Scope */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Plan Type</Label>
<Select
items={SCOPE_OPTIONS}
value={values.scope}
onValueChange={(v) => set("scope", (v as ScopeChoice) ?? "Individual")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{values.scope === "Family" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-member-limit">
Max members <span className="text-destructive">*</span>
</Label>
<Input
id="plan-member-limit"
type="number"
min={2}
value={values.memberLimit}
onChange={(e) => set("memberLimit", e.target.value)}
/>
</div>
)}
</div>
{/* Duration */}
<div className="flex flex-col gap-1.5">
<Label>Duration</Label>
<Select
items={[
{ label: "Rolling (from join date)", value: "Rolling" },
{ label: "Fixed date range", value: "FixedRange" },
]}
value={values.durationMode}
onValueChange={(v) => set("durationMode", (v as DurationMode) ?? "Rolling")}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Rolling">Rolling (from join date)</SelectItem>
<SelectItem value="FixedRange">Fixed date range</SelectItem>
</SelectContent>
</Select>
</div>
{isRolling ? (
<div className="flex flex-col gap-1.5">
<Label>
Length <span className="text-destructive">*</span>
</Label>
<Select
items={DURATION_OPTIONS}
value={customDuration ? "custom" : values.duration || null}
onValueChange={(v) => {
if (v === "custom") {
setCustomDuration(true)
set("duration", "")
} else {
setCustomDuration(false)
set("duration", v ?? "")
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select length" />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{customDuration && (
<Input
className="mt-1.5"
type="number"
min={1}
placeholder="Number of days"
value={values.duration}
onChange={(e) => set("duration", e.target.value)}
/>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-valid-from">
Valid from <span className="text-destructive">*</span>
</Label>
<Input
id="plan-valid-from"
type="date"
value={values.validFrom}
onChange={(e) => set("validFrom", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-valid-to">
Valid to <span className="text-destructive">*</span>
</Label>
<Input
id="plan-valid-to"
type="date"
value={values.validTo}
onChange={(e) => set("validTo", e.target.value)}
/>
</div>
</div>
)}
{/* Places */}
<div className="flex flex-col gap-1.5">
<Label>Places</Label>
<p className="text-xs text-muted-foreground">
Which access zones this plan reaches. Leave all unchecked for unrestricted
access (every terminal).
</p>
{places.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No places set up yet — this plan will be unrestricted.
</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border p-3">
{places.map((place) => {
const checked = values.placeIds.includes(place.placeId)
return (
<label
key={place.placeId}
className="flex items-center gap-2 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(isChecked) =>
set(
"placeIds",
isChecked
? [...values.placeIds, place.placeId]
: values.placeIds.filter((id) => id !== place.placeId)
)
}
/>
{place.name}
</label>
)
})}
</div>
)}
</div>
{/* Price */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-price">
Price (LKR) <span className="text-destructive">*</span>
</Label>
<Input
id="plan-price"
type="number"
min={0}
placeholder="Enter total price"
value={values.price}
onChange={(e) => set("price", e.target.value)}
required
/>
</div>
{/* One-time onboarding fee */}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="leading-tight">
<Label htmlFor="plan-onboarding-fe">Charge an onboarding fee</Label>
<p className="text-xs text-muted-foreground">
Added once when a new membership starts.
</p>
</div>
<Switch
id="plan-onboarding-fe"
checked={values.hasOnboardingFee}
onCheckedChange={(checked) => set("hasOnboardingFee", checked)}
/>
</div>
{values.hasOnboardingFee && (
<div className="flex flex-col gap-1.5 border-t pt-2">
<Label htmlFor="plan-onboarding-fe-amount">
Onboarding fee (LKR) <span className="text-destructive">*</span>
</Label>
<Input
id="plan-onboarding-fe-amount"
type="number"
min={0.01}
step={0.01}
placeholder="Enter one-time fee"
value={values.onboardingFeeAmount}
onChange={(e) => set("onboardingFeeAmount", e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Initial invoice total: Rs. {(price + onboardingFee).toLocaleString()}
</p>
</div>
)}
</div>
{/* Split payments */}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="leading-tight">
<Label htmlFor="plan-split">Allow split payments</Label>
<p className="text-xs text-muted-foreground">
Members pay in equal installments until settled.
</p>
</div>
<Switch
id="plan-split"
checked={values.allowSplit}
onCheckedChange={(checked) => set("allowSplit", checked)}
/>
</div>
{values.allowSplit && (
<div className="flex flex-col gap-1.5 border-t pt-2">
<Label htmlFor="plan-installments">Number of installments</Label>
<Input
id="plan-installments"
type="number"
min={2}
placeholder="e.g. 6"
value={values.installmentCount}
onChange={(e) => set("installmentCount", e.target.value)}
/>
{price > 0 && count >= 2 && (
<p className="text-xs text-muted-foreground">
Rs. {price.toLocaleString()} → {count} × Rs. {perInstallment.toLocaleString()}
</p>
)}
</div>
)}
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Add Gym Plan" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { AddGymPlanDialog }
export type { GymPlanFormValues }
@@ -0,0 +1,398 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { assignCustomerPlan } from "@/lib/api/customers"
import { addSubscriptionMember } from "@/lib/api/subscriptions"
import { useMediaQuery } from "@/lib/use-media-query"
import { runMutation } from "@/lib/use-mutation"
import { clampDiscountValue } from "@/lib/validation"
import type { GymPlan } from "@/lib/models/gym-plan"
import type { MemberSubscription } from "@/lib/models/customer"
import { SelectableCardGroup } from "@/components/hexa/selectable-card"
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 {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export interface GroupPlanOption {
subscriptionId: string
label: string
}
const DISCOUNT_TYPE_ITEMS = [
{ label: "Percentage", value: "Percentage" },
{ label: "Flat amount", value: "Flat" },
]
/** yyyy-mm-dd in local time, matching what a `type="date"` input reads/writes. */
function toIsoDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
function addDays(date: Date, days: number): Date {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
function formatDisplayDate(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}
interface AssignPlanDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
member: MemberSubscription | null
plans: GymPlan[]
/** Existing Group-scope subscriptions for this gym that still have room. */
groupOptions: GroupPlanOption[]
/** Fired after a successful assignment so the caller can refresh and, for a new Group
* plan, jump straight to adding the rest of the household. */
onAssigned: (result: { subscriptionId?: string | null; startedNewGroupPlan: boolean }) => void
/** Sandbox demo mode: mutate local state via these instead of calling the backend. */
demo?: boolean
demoAssignNewPlan?: (customerId: string, planId: string) => void
demoJoinGroupPlan?: (subscriptionId: string, customerId: string) => void
}
function AssignPlanDialog({
open,
onOpenChange,
member,
plans,
groupOptions,
onAssigned,
demo = false,
demoAssignNewPlan,
demoJoinGroupPlan,
}: AssignPlanDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [selectedPlan, setSelectedPlan] = React.useState("")
const [selectedGroup, setSelectedGroup] = React.useState("")
const [expiryMode, setExpiryMode] = React.useState<"auto" | "custom">("auto")
const [customExpiryDate, setCustomExpiryDate] = React.useState("")
const [chargePayment, setChargePayment] = React.useState(true)
const [giveDiscount, setGiveDiscount] = React.useState(false)
const [discountType, setDiscountType] = React.useState<"Percentage" | "Flat">("Percentage")
const [discountValue, setDiscountValue] = React.useState("")
const [busy, setBusy] = React.useState(false)
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSelectedPlan("")
setSelectedGroup("")
setExpiryMode("auto")
setCustomExpiryDate("")
setChargePayment(true)
setGiveDiscount(false)
setDiscountType("Percentage")
setDiscountValue("")
setBusy(false)
}
}
const planItems = plans.map((p) => ({
label: `${p.planName} — Rs. ${p.planPrice.toLocaleString()}`,
value: p.customerPlanId,
}))
const selectedPlanDetails = plans.find((p) => p.customerPlanId === selectedPlan) ?? null
const isFixedRange = selectedPlanDetails?.durationMode === "FixedRange"
// Fixed-range plans always expire on the plan's own end date; rolling plans default to
// "starts today" so the member gets the full duration, but the admin can override it.
const autoExpiryIso = selectedPlanDetails
? isFixedRange
? (selectedPlanDetails.validTo ?? null)
: toIsoDate(addDays(new Date(), selectedPlanDetails.durationInDays))
: null
function selectPlan(id: string) {
setSelectedPlan(id)
setExpiryMode("auto")
setCustomExpiryDate("")
}
async function assignNewPlan() {
if (!member || !selectedPlan || busy) return
const plan = plans.find((p) => p.customerPlanId === selectedPlan)
if (!plan) return
// The API always requires an explicit expiry date on this endpoint — never send it
// blank/undefined, or the assignment fails server-side.
let expiryDate: string | null
if (plan.durationMode === "FixedRange") {
expiryDate = plan.validTo ?? null
if (!expiryDate) {
toast.error("This plan is missing its end date — fix it from Plans first.")
return
}
} else {
expiryDate =
expiryMode === "custom" && customExpiryDate
? customExpiryDate
: toIsoDate(addDays(new Date(), plan.durationInDays))
}
setBusy(true)
try {
if (demo) {
demoAssignNewPlan?.(member.fkCustomerId, selectedPlan)
toast.success("Plan assigned")
onOpenChange(false)
return
}
const result = await runMutation(() =>
assignCustomerPlan(member.fkCustomerId, {
customerPlanId: selectedPlan,
expiryDate,
chargePayment,
discountType: giveDiscount && discountValue ? discountType : undefined,
discountValue: giveDiscount && discountValue ? Number(discountValue) : undefined,
})
)
toast.success("Plan assigned")
onAssigned({ subscriptionId: result.subscriptionId, startedNewGroupPlan: plan.planScope === "Group" })
onOpenChange(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setBusy(false)
}
}
async function joinGroupPlan() {
if (!member || !selectedGroup || busy) return
setBusy(true)
try {
if (demo) {
demoJoinGroupPlan?.(selectedGroup, member.fkCustomerId)
toast.success("Added to the group plan")
onOpenChange(false)
return
}
await runMutation(() => addSubscriptionMember(selectedGroup, member.fkCustomerId))
toast.success("Added to the group plan")
onAssigned({ subscriptionId: selectedGroup, startedNewGroupPlan: false })
onOpenChange(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setBusy(false)
}
}
const Footer = isDesktop ? DialogFooter : SheetFooter
const footerClassName = isDesktop ? "-mx-4 -mt-3 -mb-4" : "flex-col-reverse"
const buttonWidthClassName = isDesktop ? undefined : "w-full"
const body = (
<Tabs defaultValue="new">
<TabsList className="w-full">
<TabsTrigger value="new" className="flex-1">New plan</TabsTrigger>
<TabsTrigger value="join" className="flex-1">Join group plan</TabsTrigger>
</TabsList>
<TabsContent value="new" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Membership Plan</Label>
<Select items={planItems} value={selectedPlan || null} onValueChange={(v) => selectPlan(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select a plan" /></SelectTrigger>
<SelectContent>{planItems.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Starts a new subscription with {member?.customer?.cusName ?? "this member"} as the primary member.
</p>
</div>
{selectedPlanDetails && isFixedRange && (
<p className="text-xs text-muted-foreground">
Fixed-term plan — runs through{" "}
{autoExpiryIso ? formatDisplayDate(autoExpiryIso) : "the plan's end date"}.
</p>
)}
{selectedPlanDetails && !isFixedRange && (
<div className="flex flex-col gap-1.5">
<Label>Expiry date</Label>
<SelectableCardGroup
columns={2}
value={expiryMode}
onValueChange={(v) => setExpiryMode(v as "auto" | "custom")}
options={[
{
value: "auto",
label: "Starts now",
description: autoExpiryIso
? `Ends ${formatDisplayDate(autoExpiryIso)} (${selectedPlanDetails.durationInDays}-day plan)`
: "Uses the plan's duration",
},
{ value: "custom", label: "Pick a date", description: "Choose a specific expiry date" },
]}
/>
{expiryMode === "custom" && (
<Input
type="date"
value={customExpiryDate || autoExpiryIso || ""}
min={toIsoDate(new Date())}
onChange={(e) => setCustomExpiryDate(e.target.value)}
/>
)}
</div>
)}
{selectedPlanDetails && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="leading-tight">
<Label htmlFor="assign-charge-payment">Charge Payment</Label>
<p className="text-xs text-muted-foreground">
{chargePayment
? "The invoice will bill the plan price as usual."
: "The invoice will be created for Rs. 0.00 and marked Paid automatically — no payment link will be sent."}
</p>
</div>
<Switch id="assign-charge-payment" checked={chargePayment} onCheckedChange={setChargePayment} />
</div>
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="leading-tight">
<Label htmlFor="assign-give-discount">Give Discount</Label>
<p className="text-xs text-muted-foreground">
Optionally reduce the plan price for this member.
</p>
</div>
<Switch id="assign-give-discount" checked={giveDiscount} onCheckedChange={setGiveDiscount} />
</div>
{giveDiscount && (
<div className="flex gap-2">
<Select
items={DISCOUNT_TYPE_ITEMS}
value={discountType}
onValueChange={(v) => {
const nextType = (v as "Percentage" | "Flat") ?? "Percentage"
setDiscountType(nextType)
setDiscountValue((prev) => clampDiscountValue(prev, nextType, selectedPlanDetails?.planPrice))
}}
>
<SelectTrigger className="w-36 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISCOUNT_TYPE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={0}
max={discountType === "Percentage" ? 100 : selectedPlanDetails?.planPrice}
placeholder={discountType === "Percentage" ? "e.g. 10 (%)" : "e.g. 500 (Rs.)"}
value={discountValue}
onChange={(e) =>
setDiscountValue(clampDiscountValue(e.target.value, discountType, selectedPlanDetails?.planPrice))
}
/>
</div>
)}
</div>
</>
)}
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
type="button"
className={buttonWidthClassName}
onClick={assignNewPlan}
disabled={!selectedPlan || busy || (expiryMode === "custom" && !customExpiryDate)}
>
{busy && <Spinner className="size-4" />}Assign plan
</Button>
</Footer>
</TabsContent>
<TabsContent value="join" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Existing group plan</Label>
<Select items={groupOptions.map((g) => ({ label: g.label, value: g.subscriptionId }))} value={selectedGroup || null} onValueChange={(v) => setSelectedGroup(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder={groupOptions.length ? "Choose a group plan" : "No group plans with room"} /></SelectTrigger>
<SelectContent>{groupOptions.map((opt) => <SelectItem key={opt.subscriptionId} value={opt.subscriptionId}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Adds {member?.customer?.cusName ?? "this member"} as a member of an existing couple/family plan — no separate charge.
</p>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" className={buttonWidthClassName} onClick={joinGroupPlan} disabled={!selectedGroup || busy}>{busy && <Spinner className="size-4" />}Add to plan</Button>
</Footer>
</TabsContent>
</Tabs>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign a plan</DialogTitle>
<DialogDescription>{member?.customer?.cusName ?? "This member"} doesn&apos;t have a plan yet.</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent side="bottom" className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]">
<SheetHeader>
<SheetTitle>Assign a plan</SheetTitle>
<SheetDescription>{member?.customer?.cusName ?? "This member"} doesn&apos;t have a plan yet.</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
export { AssignPlanDialog }
@@ -0,0 +1,226 @@
"use client"
import * as React from "react"
import { CalendarCheckIcon, CalendarIcon, TrendingUpIcon, UsersIcon } from "lucide-react"
import {
getAllAttendance,
getAttendanceCounts,
getTodayAttendance,
} from "@/lib/api/attendance"
import {
DEMO_ATTENDANCE_COUNTS,
DEMO_ATTENDANCE_HISTORY,
DEMO_TODAY_ATTENDANCE,
} from "@/lib/demo/mock-data"
import { formatDate, formatTime } from "@/lib/format"
import { sortByNewest } from "@/lib/validation"
import type { AttendanceRecord } from "@/lib/models/attendance"
import { subscribeAccessEvents } from "@/lib/realtime/access-events"
import { useFetch } from "@/lib/use-fetch"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CheckinList } from "@/components/hexa/checkin-list"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageHeader } from "@/components/hexa/page-header"
import { StatCard } from "@/components/hexa/stat-card"
import { StatusBadge } from "@/components/hexa/status-badge"
function sameDay(iso: string, day: Date) {
const d = new Date(iso)
return (
d.getFullYear() === day.getFullYear() &&
d.getMonth() === day.getMonth() &&
d.getDate() === day.getDate()
)
}
function personName(record: AttendanceRecord) {
return (
record.customer?.cusName ??
record.accessProfile?.displayName ??
record.user?.userName ??
"Unknown"
)
}
interface AttendanceViewProps {
demo?: boolean
}
function AttendanceView({ demo = false }: AttendanceViewProps) {
const today = useFetch(demo ? async () => DEMO_TODAY_ATTENDANCE : getTodayAttendance)
const all = useFetch(demo ? async () => DEMO_ATTENDANCE_HISTORY : getAllAttendance)
const counts = useFetch(demo ? async () => DEMO_ATTENDANCE_COUNTS : () => getAttendanceCounts())
const reloadToday = today.reload
const reloadAll = all.reload
const reloadCounts = counts.reload
const [historyDay, setHistoryDay] = React.useState<Date | undefined>(undefined)
React.useEffect(() => {
if (demo) return
const reload = () => {
reloadToday()
reloadAll()
reloadCounts()
}
const unsubscribe = subscribeAccessEvents((event) => {
if (event.type === "attendance") reload()
})
const timer = window.setInterval(reload, 15_000)
return () => {
unsubscribe()
window.clearInterval(timer)
}
}, [demo, reloadAll, reloadCounts, reloadToday])
const records = sortByNewest(all.data ?? [], (r) => r.eventTime)
const historyRows = historyDay
? records.filter((r) => sameDay(r.eventTime, historyDay))
: records
const monthTotal = (counts.data ?? []).reduce((sum, c) => sum + c.attendanceCount, 0)
const peopleToday = today.data?.length ?? 0
const currentlyIn = (today.data ?? []).filter(
(entry) => !entry.eventType?.toLowerCase().includes("out")
).length
const columns: DataTableColumn<AttendanceRecord>[] = [
{
key: "member",
header: "Person",
cell: (row) => <span className="font-medium">{personName(row)}</span>,
},
{
key: "date",
header: "Date",
cell: (row) => <span className="text-muted-foreground">{formatDate(row.eventTime)}</span>,
},
{
key: "time",
header: "Time",
cell: (row) => <span className="text-muted-foreground">{formatTime(row.eventTime)}</span>,
},
{
key: "type",
header: "Event",
cell: (row) => (
<StatusBadge status={row.eventType?.toLowerCase().includes("out") ? "neutral" : "success"}>
{row.eventType || "IN"}
</StatusBadge>
),
},
]
return (
<div className="flex flex-col gap-4">
<PageHeader title="Attendance" description="Latest attendance state for each person" />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
icon={CalendarCheckIcon}
label="People Today"
value={today.loading ? "…" : peopleToday}
hint="Latest record per person"
/>
<StatCard
icon={UsersIcon}
tone="blue"
label="Currently In"
value={today.loading ? "…" : currentlyIn}
hint="Based on latest event"
/>
<StatCard
icon={TrendingUpIcon}
tone="green"
label="This Month"
value={counts.loading ? "…" : monthTotal}
hint="Total check-ins"
/>
<StatCard
icon={CalendarIcon}
tone="purple"
label="People Tracked"
value={all.loading ? "…" : records.length}
hint="Latest record per person"
/>
</div>
<Tabs defaultValue="today">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="today" className="flex-1 sm:flex-none">Today</TabsTrigger>
<TabsTrigger value="history" className="flex-1 sm:flex-none">History</TabsTrigger>
</TabsList>
<TabsContent value="today" className="mt-3">
<CheckinList
entries={
today.data?.map((t) => ({ name: t.customerName, time: t.eventTime })) ?? null
}
loading={today.loading}
/>
</TabsContent>
<TabsContent value="history" className="mt-3 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="size-4" />
{historyDay ? formatDate(historyDay.toISOString()) : "All dates"}
</Button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={historyDay}
onSelect={(d) => setHistoryDay(d ?? undefined)}
/>
</PopoverContent>
</Popover>
{historyDay && (
<Button variant="ghost" size="sm" onClick={() => setHistoryDay(undefined)}>
Clear
</Button>
)}
</div>
<DataTable
columns={columns}
rows={historyRows.slice(0, 100)}
rowKey={(row) => row.attendanceId}
emptyMessage={
all.loading
? "Loading attendance records…"
: historyDay
? "No check-ins on this date."
: "No attendance records yet."
}
renderMobileCard={(row) => (
<div className="flex items-center justify-between gap-2">
<div className="leading-tight">
<p className="font-medium">{personName(row)}</p>
<p className="text-xs text-muted-foreground">
{formatDate(row.eventTime)} · {formatTime(row.eventTime)}
</p>
</div>
<StatusBadge
status={row.eventType?.toLowerCase().includes("out") ? "neutral" : "success"}
>
{row.eventType || "IN"}
</StatusBadge>
</div>
)}
/>
</TabsContent>
</Tabs>
</div>
)
}
export { AttendanceView }
@@ -0,0 +1,474 @@
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, PlusIcon, Trash2Icon, WalletIcon } from "lucide-react"
import { toast } from "sonner"
import {
cancelBooking,
checkInBooking,
createGuestBooking,
getSessionBookings,
markGuestBookingPaid,
} from "@/lib/api/bookings"
import { getFacilitySessions } from "@/lib/api/facilities"
import type { Booking } from "@/lib/models/booking"
import type { Facility, 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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { InfoBanner } from "@/components/hexa/info-banner"
import { StatusBadge } from "@/components/hexa/status-badge"
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
})
}
function bookingStatusTone(status: string) {
if (status === "Cancelled") return "danger" as const
if (status === "CheckedIn") return "success" as const
return "info" as const
}
function paymentStatusTone(status: string) {
if (status === "Paid" || status === "NotRequired") return "success" as const
if (status === "Unpaid") return "warning" as const
return "neutral" as const
}
interface BookingRosterTabProps {
facilities: Facility[]
}
function BookingRosterTab({ facilities }: BookingRosterTabProps) {
const sessionsFetch = useFetch(() => getFacilitySessions({}))
const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled)
const [sessionId, setSessionId] = React.useState("")
React.useEffect(() => {
if (!sessionId && sessions.length > 0) setSessionId(sessions[0].facilitySessionId)
// eslint-disable-next-line react-hooks/exhaustive-deps -- only auto-select once sessions load
}, [sessions.length])
const bookingsFetch = useFetch(() => (sessionId ? getSessionBookings(sessionId) : Promise.resolve([])))
const bookings = bookingsFetch.data ?? []
const [otpCode, setOtpCode] = React.useState("")
const { busy: checkingIn, run: runCheckIn } = useMutation()
const [payTarget, setPayTarget] = React.useState<Booking | null>(null)
const [cancelTarget, setCancelTarget] = React.useState<Booking | null>(null)
const [guestDialogOpen, setGuestDialogOpen] = React.useState(false)
const sessionOptions = sessions.map((s) => ({
label: `${s.facilityName ?? "-"} · ${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
value: s.facilitySessionId,
}))
async function handleCheckIn(e: React.FormEvent) {
e.preventDefault()
if (!otpCode.trim()) return
try {
await runCheckIn(() => checkInBooking(otpCode.trim()))
toast.success("Checked in")
setOtpCode("")
bookingsFetch.reload()
} catch {
// run() already toasted the real error.
}
}
async function confirmCancel() {
if (!cancelTarget) return
const target = cancelTarget
setCancelTarget(null)
try {
await runMutation(() => cancelBooking(target.bookingId))
toast.success("Booking cancelled")
bookingsFetch.reload()
} catch {
// runMutation() already toasted the real error.
}
}
function copyShareLink(booking: Booking) {
if (!booking.shareUrl) return
navigator.clipboard.writeText(booking.shareUrl)
toast.success("Link copied", { description: "Share it with the guest so they have their receipt." })
}
const columns: DataTableColumn<Booking>[] = [
{
key: "who",
header: "Booked By",
cell: (row) => (
<div>
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
</div>
),
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>,
},
{
key: "payment",
header: "Payment",
cell: (row) => (
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>
{row.paymentStatus}
{row.amountCharged > 0 ? ` · Rs. ${row.amountCharged.toLocaleString()}` : ""}
</StatusBadge>
),
},
{ key: "booked-at", header: "Booked At", cell: (row) => formatDateTime(row.bookedAt) },
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => (
<div className="flex justify-center gap-1.5">
{row.shareUrl && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
<CopyIcon />
</Button>
}
/>
<TooltipContent>Copy share link</TooltipContent>
</Tooltip>
)}
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
<WalletIcon />
</Button>
}
/>
<TooltipContent>Mark as paid</TooltipContent>
</Tooltip>
)}
{row.status !== "Cancelled" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
}
/>
<TooltipContent>Cancel booking</TooltipContent>
</Tooltip>
)}
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleCheckIn} className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="checkin-otp">Check in by OTP</Label>
<Input
id="checkin-otp"
placeholder="Enter the guest's OTP code"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
className="w-56"
/>
</div>
<Button type="submit" disabled={!otpCode.trim() || checkingIn}>
{checkingIn ? <Spinner className="size-4" /> : <CheckIcon />}
Check In
</Button>
</form>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Label>Session</Label>
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
<SelectTrigger className="w-96 max-w-full">
<SelectValue placeholder="Select a session" />
</SelectTrigger>
<SelectContent>
{sessionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={() => setGuestDialogOpen(true)} disabled={!sessionId}>
<PlusIcon />
Add Walk-in Guest
</Button>
</div>
{!sessionId ? (
<InfoBanner>Schedule a session first, then come back here to manage its roster.</InfoBanner>
) : bookingsFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
) : (
<DataTable
columns={columns}
rows={bookings}
rowKey={(row) => row.bookingId}
emptyMessage="No bookings for this session 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.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
</div>
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2">
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>{row.paymentStatus}</StatusBadge>
<div className="flex gap-1.5">
{row.shareUrl && (
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
<CopyIcon />
</Button>
)}
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
<WalletIcon />
</Button>
)}
{row.status !== "Cancelled" && (
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
)}
</div>
</div>
</div>
)}
/>
)}
<AddWalkInGuestDialog
open={guestDialogOpen}
onOpenChange={setGuestDialogOpen}
facilitySessionId={sessionId}
onCreated={bookingsFetch.reload}
/>
<MarkPaidDialog booking={payTarget} onOpenChange={(open) => !open && setPayTarget(null)} onPaid={bookingsFetch.reload} />
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this booking?</AlertDialogTitle>
<AlertDialogDescription>This frees up a spot in the session. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
<AlertDialogAction onClick={confirmCancel}>Cancel Booking</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface AddWalkInGuestDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
facilitySessionId: string
onCreated: () => void
}
function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated }: AddWalkInGuestDialogProps) {
const [guestName, setGuestName] = React.useState("")
const [guestPhone, setGuestPhone] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setGuestName("")
setGuestPhone("")
}
}
const canSubmit = !!guestName.trim() && !!guestPhone.trim() && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
const booking = await run(() =>
createGuestBooking({ facilitySessionId, guestName: guestName.trim(), guestPhone: guestPhone.trim() })
)
if (booking.shareUrl) {
navigator.clipboard.writeText(booking.shareUrl)
toast.success("Booking created — share link copied", {
description: `OTP ${booking.otpCode} for ${booking.guestName}.`,
})
} else {
toast.success("Booking created", { description: `OTP ${booking.otpCode} for ${booking.guestName}.` })
}
onOpenChange(false)
onCreated()
} 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>Add Walk-in Guest</DialogTitle>
<DialogDescription>
Books a spot for a non-member and hands them an OTP receipt with a shareable link.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="guest-name">
Guest Name <span className="text-destructive">*</span>
</Label>
<Input id="guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="guest-phone">
Mobile Number <span className="text-destructive">*</span>
</Label>
<Input id="guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Add Guest Booking
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
interface MarkPaidDialogProps {
booking: Booking | null
onOpenChange: (open: boolean) => void
onPaid: () => void
}
function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps) {
const [amount, setAmount] = React.useState("")
const { busy, run } = useMutation()
const [prevBooking, setPrevBooking] = React.useState(booking)
if (booking !== prevBooking) {
setPrevBooking(booking)
setAmount(booking?.amountCharged ? String(booking.amountCharged) : "")
}
const canSubmit = !!booking && Number(amount) > 0 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!booking || !canSubmit) return
try {
await run(() => markGuestBookingPaid(booking.bookingId, Number(amount)))
toast.success("Payment recorded")
onOpenChange(false)
onPaid()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={!!booking} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Mark as Paid</DialogTitle>
<DialogDescription>Record the amount collected from {booking?.guestName ?? "this guest"}.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="paid-amount">
Amount (Rs.) <span className="text-destructive">*</span>
</Label>
<Input
id="paid-amount"
type="number"
min={0.01}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Confirm Payment
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { BookingRosterTab }
+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 }
@@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { sendContactOtp, verifyContactOtp } from "@/lib/api/profile"
import { useMutation } from "@/lib/use-mutation"
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 {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { Spinner } from "@/components/ui/spinner"
interface ChangeContactDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
type: "email" | "phone"
currentValue?: string | null
onChanged: () => void
}
export function ChangeContactDialog({
open,
onOpenChange,
type,
currentValue,
onChanged,
}: ChangeContactDialogProps) {
const [step, setStep] = React.useState<"value" | "otp">("value")
const [value, setValue] = React.useState("")
const [referenceNumber, setReferenceNumber] = React.useState("")
const [otp, setOtp] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
// Reset the flow each time the dialog opens.
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setStep("value")
setValue("")
setReferenceNumber("")
setOtp("")
}
}
const label = type === "email" ? "email address" : "mobile number"
async function handleSend(e: React.FormEvent) {
e.preventDefault()
if (!value.trim()) {
toast.error(`Enter your new ${label}.`)
return
}
try {
const { referenceNumber } = await run(() => sendContactOtp(type, value.trim()))
setReferenceNumber(referenceNumber)
setStep("otp")
toast.success(`We sent a code to your new ${label}.`)
} catch {
// run() already toasted the real error.
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
if (otp.length < 6) {
toast.error("Enter the 6-digit code.")
return
}
try {
await run(() =>
verifyContactOtp({
type,
identifier: value.trim(),
referenceNumber,
otpCode: otp,
})
)
toast.success(`Your ${label} was updated.`)
onChanged()
onOpenChange(false)
} catch {
// run() already toasted the real error.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change {label}</DialogTitle>
<DialogDescription>
{step === "value"
? `Enter your new ${label}. We'll send a code to verify it.`
: `Enter the code we sent to ${value}.`}
</DialogDescription>
</DialogHeader>
{step === "value" ? (
<form onSubmit={handleSend} className="flex flex-col gap-4">
{currentValue && (
<p className="text-xs text-muted-foreground">
Current: <span className="font-medium text-foreground">{currentValue}</span>
</p>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-contact">New {label}</Label>
<Input
id="new-contact"
type={type === "email" ? "email" : "tel"}
placeholder={type === "email" ? "you@example.com" : "07XXXXXXXX"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={busy}>
{busy && <Spinner className="size-4" />}
Send code
</Button>
</DialogFooter>
</form>
) : (
<form onSubmit={handleVerify} className="flex flex-col gap-4">
<div className="flex justify-center py-1">
<InputOTP maxLength={6} value={otp} onChange={setOtp} containerClassName="justify-center">
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} className="size-10 text-base" />
))}
</InputOTPGroup>
</InputOTP>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => setStep("value")}>
Back
</Button>
<Button type="submit" disabled={busy}>
{busy && <Spinner className="size-4" />}
Verify & save
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import { CheckIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { changeMembershipPlan, type ChangeMembershipPlanResult } from "@/lib/api/customers"
import type { MemberSubscription } from "@/lib/models/customer"
import type { GymPlan } from "@/lib/models/gym-plan"
import { formatCurrency } from "@/lib/format"
import { useMediaQuery } from "@/lib/use-media-query"
import { useMutation } from "@/lib/use-mutation"
import { cn } from "@/lib/utils"
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 {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"
/** Turns the backend's settlement result into a plain-language toast description. */
function describeSettlement(result: ChangeMembershipPlanResult): string | undefined {
const amount = result.invoiceAmount
if (amount == null) return undefined
if (result.invoiceStatus === "Credit")
return `They overpaid on the old plan -- ${formatCurrency(-amount)} credit applied automatically to their next invoice.`
if (amount === 0) return "Settled exactly -- no balance due."
return `${formatCurrency(amount)} due on the new plan.`
}
interface ChangeMembershipDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
member: MemberSubscription | null
/** All gym plans — filtered here to active Individual-scope ones. */
plans: GymPlan[]
onChanged: () => void
/** Sandbox demo mode: mutate local state instead of calling the backend. */
demo?: boolean
demoChangeMembership?: (customerId: string, planId: string) => void
}
function ChangeMembershipDialog({
open,
onOpenChange,
member,
plans,
onChanged,
demo = false,
demoChangeMembership,
}: ChangeMembershipDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [search, setSearch] = React.useState("")
const [selectedPlanId, setSelectedPlanId] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSearch("")
setSelectedPlanId("")
}
}
const individualPlans = plans.filter((p) => p.isActive && p.planScope === "Individual")
const options = individualPlans.filter(
(p) => !search || p.planName.toLowerCase().includes(search.toLowerCase())
)
async function handleConfirm() {
if (!member || !selectedPlanId || busy) return
if (demo) {
demoChangeMembership?.(member.fkCustomerId, selectedPlanId)
toast.success("Membership plan changed")
onChanged()
onOpenChange(false)
return
}
try {
const result = await run(() => changeMembershipPlan(member.fkCustomerId, selectedPlanId))
toast.success("Membership plan changed", { description: describeSettlement(result) })
onChanged()
onOpenChange(false)
} catch {
// run() already toasted the real error.
}
}
const Footer = isDesktop ? DialogFooter : SheetFooter
const footerClassName = isDesktop ? "-mx-4 -mt-3 -mb-4" : "flex-col-reverse"
const buttonWidthClassName = isDesktop ? undefined : "w-full"
const body = (
<div className="flex flex-col gap-3">
{(member?.balance ?? 0) > 0 && (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
This member has an outstanding balance of {formatCurrency(member?.balance ?? 0)} on their
current plan — it stays on the old invoice and isn&apos;t cancelled by switching.
</p>
)}
{individualPlans.length > 5 && (
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search plans..."
className="h-9 pl-8"
/>
</div>
)}
{options.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
No matching plans.
</p>
) : (
<div className="max-h-64 overflow-y-auto rounded-lg border">
<Table>
<TableBody>
{options.map((plan) => {
const isCurrent = plan.customerPlanId === member?.fkCustomerPlanId
const isSelected = plan.customerPlanId === selectedPlanId
return (
<TableRow
key={plan.customerPlanId}
className={cn(
"border-b-0",
isCurrent ? "cursor-not-allowed opacity-60" : "cursor-pointer",
isSelected && "bg-accent"
)}
onClick={() => !isCurrent && setSelectedPlanId(plan.customerPlanId)}
>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{plan.planName}</span>
{isCurrent && <Badge variant="outline">Current</Badge>}
</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-muted-foreground">
Rs. {plan.planPrice.toLocaleString()}
</TableCell>
<TableCell className="w-6 pl-0">
{isSelected && <CheckIcon className="size-4 text-primary" />}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
className={buttonWidthClassName}
onClick={handleConfirm}
disabled={!selectedPlanId || busy}
>
{busy && <Spinner className="size-4" />}
Change Membership
</Button>
</Footer>
</div>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Membership</DialogTitle>
<DialogDescription>
Choose a new plan for {member?.customer?.cusName ?? "this member"}.
</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>Change Membership</SheetTitle>
<SheetDescription>
Choose a new plan for {member?.customer?.cusName ?? "this member"}.
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
export { ChangeMembershipDialog }
@@ -0,0 +1,146 @@
"use client"
import * as React from "react"
import { ImageIcon } from "lucide-react"
import type { CreateFeedInput } from "@/lib/models/feed"
import { useMutation } from "@/lib/use-mutation"
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 { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
resolve(result.slice(result.indexOf(",") + 1))
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
interface CreateFeedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (input: CreateFeedInput) => Promise<void>
}
function CreateFeedDialog({ open, onOpenChange, onSubmit }: CreateFeedDialogProps) {
const [caption, setCaption] = React.useState("")
const [description, setDescription] = React.useState("")
const [file, setFile] = React.useState<File | null>(null)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setCaption("")
setDescription("")
setFile(null)
}
}
// The backend requires an image — feed posts without a document are rejected.
const canSubmit = caption.trim() && file && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit || !file) return
try {
await run(async () => {
const base64 = await fileToBase64(file)
await onSubmit({
caption: caption.trim(),
description: description.trim() || undefined,
document: [{ FileName: file.name, Base64: base64 }],
})
})
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open so the user can retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>New Feed Post</DialogTitle>
<DialogDescription>
Share an announcement or update with your members.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="feed-caption">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="feed-caption"
placeholder="Post title"
value={caption}
onChange={(e) => setCaption(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="feed-description">Description</Label>
<Textarea
id="feed-description"
placeholder="Write something for your members…"
maxLength={500}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="feed-image">
Image <span className="text-destructive">*</span>
</Label>
<Input
id="feed-image"
type="file"
accept=".jpg,.jpeg,.png"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
{file && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ImageIcon className="size-3.5" />
{file.name} ({Math.round(file.size / 1024)} KB)
</span>
)}
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Publish Post
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { CreateFeedDialog }
@@ -0,0 +1,686 @@
"use client"
import * as React from "react"
import { BanknoteIcon, CalendarCheckIcon, CircleCheckIcon, ClockIcon, CreditCardIcon, HistoryIcon, ReceiptIcon } from "lucide-react"
import { toast } from "sonner"
import { getCustomerInvoiceHistory, getMemberInvoices, sendPaymentLink } from "@/lib/api/invoices"
import { getSubscription, recordSubscriptionPayment } from "@/lib/api/subscriptions"
import { getStatSummary } from "@/lib/api/stats"
import { formatCurrency, formatDate, formatDateTime } from "@/lib/format"
import { cn } from "@/lib/utils"
import { sortByNewest } from "@/lib/validation"
import { DEMO_GYM_PLANS, DEMO_MEMBER_PROFILES, DEMO_SUBSCRIPTIONS } from "@/lib/demo/mock-data"
import type { MemberProfile } from "@/lib/models/customer"
import type { GymPlan } from "@/lib/models/gym-plan"
import type { MemberInvoice } from "@/lib/models/invoice"
import type { SubscriptionSummary } from "@/lib/models/subscription"
import { useFetch } from "@/lib/use-fetch"
import { runMutation } from "@/lib/use-mutation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { DetailPanel } from "@/components/hexa/detail-panel"
import { PaymentQuickActionsDialog } from "@/components/hexa/views/payment-quick-actions-dialog"
import { ReceiptPreviewDialog } from "@/components/hexa/views/receipt-preview-dialog"
import { RecordPaymentDialog } from "@/components/hexa/views/record-payment-dialog"
import { RenewalApprovalsView } from "@/components/hexa/views/renewal-approvals-view"
import { FilterBar } from "@/components/hexa/filter-bar"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { PaginationBar } from "@/components/hexa/pagination-bar"
import { StatCard } from "@/components/hexa/stat-card"
import { StatusBadge } from "@/components/hexa/status-badge"
function statusTone(status: string): "success" | "warning" | "danger" | "neutral" {
const s = status.toLowerCase()
if (s === "paid" || s === "success" || s === "approved") return "success"
if (s === "partial") return "warning"
if (s === "pending") return "warning"
if (s === "failed" || s === "overdue") return "danger"
return "neutral"
}
/** Same calendar day as "now", in the viewer's local timezone. */
function isToday(iso: string): boolean {
const d = new Date(iso)
const now = new Date()
return d.toDateString() === now.toDateString()
}
function daysOverdue(invoice: MemberInvoice): number {
const balance = invoice.balance ?? invoice.amount
if (balance <= 0) return 0
const ms = Date.now() - new Date(invoice.customerInvoiceDate).getTime()
return Math.max(0, Math.floor(ms / (24 * 3600 * 1000)))
}
/** Derives invoice rows straight from the demo subscription table, mirroring GET client/member-invoices. */
function buildDemoInvoiceRows(subscriptions: SubscriptionSummary[], profiles: MemberProfile[], gymPlans: GymPlan[]): MemberInvoice[] {
return subscriptions.map((sub) => {
const primary = sub.members.find((m) => m.isPrimary) ?? sub.members[0]
const customer = profiles.find((c) => c.cusId === primary?.customerId) ?? null
const plan = gymPlans.find((p) => p.customerPlanId === sub.customerPlanId) ?? null
return {
customerInvoiceId: sub.customerInvoiceId ?? `inv-${sub.subscriptionId}`,
fkCustomerSubscriptionId: sub.subscriptionId,
fkClientId: "demo-gym-pulse-fitness",
amount: sub.invoiceAmount,
status: sub.paymentStatus,
isActive: true,
paidAmount: sub.paidAmount,
balance: sub.balance,
customerInvoiceDate: `${sub.onboardDate}T09:00:00+05:30`,
customerSubscription: {
subscriptionId: sub.subscriptionId,
onboardDate: sub.onboardDate,
expiryDate: sub.expiryDate,
renewalDate: sub.renewalDate,
customer,
customerPlan: plan,
},
}
})
}
interface CustomerPaymentsViewProps {
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function CustomerPaymentsView({ demo = false }: CustomerPaymentsViewProps) {
// scope=latest: one row per customer (their latest approved invoice) -- older cycles
// (Paid/RolledOver/Cancelled) stay reachable via "view previous invoices" per row.
const invoices = useFetch(demo ? async () => [] as MemberInvoice[] : () => getMemberInvoices("latest"))
const [demoSubs, setDemoSubs] = React.useState<SubscriptionSummary[]>(DEMO_SUBSCRIPTIONS)
// Muscle Beast-only pilot ("Today's payments") -- gated server-side, same summary endpoint
// the dashboard already uses to decide whether to show it.
const summary = useFetch(demo ? async () => null : () => getStatSummary())
const showTodayPayments = summary.data?.showTodayPayments ?? false
const [search, setSearch] = React.useState("")
const [status, setStatus] = React.useState("all")
const [bucket, setBucket] = React.useState<"all" | "overdue" | "done" | "today">("all")
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(10)
const [selected, setSelected] = React.useState<MemberInvoice | null>(null)
const [receiptInvoice, setReceiptInvoice] = React.useState<MemberInvoice | null>(null)
const [payOpen, setPayOpen] = React.useState(false)
const [paySummary, setPaySummary] = React.useState<SubscriptionSummary | null>(null)
const [payLoadingId, setPayLoadingId] = React.useState<string | null>(null)
const [actionsOpen, setActionsOpen] = React.useState(false)
const [actionsInvoice, setActionsInvoice] = React.useState<MemberInvoice | null>(null)
const [sendingLink, setSendingLink] = React.useState(false)
const [historyOpen, setHistoryOpen] = React.useState(false)
const [historyMemberName, setHistoryMemberName] = React.useState<string | null>(null)
const [historyRows, setHistoryRows] = React.useState<MemberInvoice[] | null>(null)
const [historyLoading, setHistoryLoading] = React.useState(false)
// The next installment still genuinely owing -- not just the first one not yet marked "Paid"
// (which can be "Partial" with some of it already paid off) and not its original `amount`
// (bug: that over-large value passed the overall-balance check on submit but got rejected by
// the backend's per-installment balance check). `> 0` also guards against suggesting Rs. 0 for
// an installment that's fully paid but not yet flagged "Paid".
const nextDueInstallment = paySummary?.installments.find(
(i) => i.status !== "Paid" && i.amount - i.paidAmount > 0
)
const suggestedInstallmentAmount = nextDueInstallment
? Math.max(0, nextDueInstallment.amount - nextDueInstallment.paidAmount)
: undefined
const rows = React.useMemo(
() =>
demo
? sortByNewest(buildDemoInvoiceRows(demoSubs, DEMO_MEMBER_PROFILES, DEMO_GYM_PLANS), (r) => r.customerInvoiceDate)
: sortByNewest(invoices.data ?? [], (r) => r.customerInvoiceDate),
[demo, demoSubs, invoices.data]
)
const filtered = rows.filter((inv) => {
const memberName = inv.customerName ?? inv.customerSubscription?.customer?.cusName ?? ""
const planName = inv.customerSubscription?.customerPlan?.planName ?? ""
const matchesSearch =
!search ||
memberName.toLowerCase().includes(search.toLowerCase()) ||
planName.toLowerCase().includes(search.toLowerCase())
const matchesStatus = status === "all" || inv.status.toLowerCase() === status
const matchesBucket =
bucket === "all"
? true
: bucket === "overdue"
? (inv.balance ?? inv.amount) > 0
: bucket === "today"
? isToday(inv.customerInvoiceDate)
: inv.status.toLowerCase() === "paid"
return matchesSearch && matchesStatus && matchesBucket
})
const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1)
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize)
const totalBilled = rows.reduce((sum, i) => sum + i.amount, 0)
const paid = rows.filter((i) => i.status.toLowerCase() === "paid")
// "Outstanding" = anything not fully paid (pending + partial).
const outstanding = rows.filter((i) => {
const s = i.status.toLowerCase()
return s === "pending" || s === "partial"
})
const dueToday = rows.filter((i) => isToday(i.customerInvoiceDate))
const outstandingBalance = outstanding.reduce((s, i) => s + (i.balance ?? i.amount), 0)
async function handleOpenPay(row: MemberInvoice) {
setPayLoadingId(row.customerInvoiceId)
if (demo) {
const summary = demoSubs.find((s) => s.subscriptionId === row.fkCustomerSubscriptionId) ?? null
if (summary) {
setPaySummary(summary)
setActionsInvoice(row)
setActionsOpen(true)
}
setPayLoadingId(null)
return
}
try {
const summary = await getSubscription(row.fkCustomerSubscriptionId)
setPaySummary(summary)
setActionsInvoice(row)
setActionsOpen(true)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Couldn't load this subscription.")
} finally {
setPayLoadingId(null)
}
}
async function handleViewHistory(row: MemberInvoice) {
const customerId = row.fkCustomerId
const name = row.customerName ?? row.customerSubscription?.customer?.cusName ?? "this member"
setHistoryMemberName(name)
setHistoryOpen(true)
setHistoryRows(null)
if (demo || !customerId) {
setHistoryRows([])
return
}
setHistoryLoading(true)
try {
setHistoryRows(await getCustomerInvoiceHistory(customerId))
} catch (err) {
toast.error(err instanceof Error ? err.message : "Couldn't load previous invoices.")
setHistoryRows([])
} finally {
setHistoryLoading(false)
}
}
async function handleSendLink() {
if (!actionsInvoice || sendingLink) return
setSendingLink(true)
if (demo) {
toast.success("Payment link sent to the member.")
setActionsOpen(false)
setSendingLink(false)
return
}
try {
await runMutation(() => sendPaymentLink(actionsInvoice.customerInvoiceId))
toast.success("Payment link sent to the member.")
setActionsOpen(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setSendingLink(false)
}
}
function handleMarkPaid() {
setActionsOpen(false)
setPayOpen(true)
}
async function handleRecordPayment(
amount: number,
note: string,
paymentMethod: "Cash" | "Bank",
bankRefNo: string
) {
if (!paySummary) return
if (demo) {
setDemoSubs((prev) =>
prev.map((s) => {
if (s.subscriptionId !== paySummary.subscriptionId) return s
const paidAmount = s.paidAmount + amount
const balance = Math.max(s.invoiceAmount - paidAmount, 0)
return { ...s, paidAmount, balance, paymentStatus: balance <= 0 ? "Paid" : "Partial" }
})
)
toast.success("Payment recorded")
return
}
await recordSubscriptionPayment(paySummary.subscriptionId, { amount, note, paymentMethod, bankRefNo })
toast.success("Payment recorded")
invoices.reload()
}
const columns: DataTableColumn<MemberInvoice>[] = [
{
key: "member",
header: "Member",
cell: (row) => (
<div className="leading-tight">
<p className="font-medium">
{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.customerPlan?.planName ?? "—"}
</p>
</div>
),
},
{
key: "date",
header: "Expire Date",
cell: (row) => (
<span className="text-muted-foreground">
{row.customerSubscription?.renewalDate ? formatDate(row.customerSubscription.renewalDate) : "—"}
</span>
),
},
{
key: "amount",
header: "Amount",
cell: (row) => <span className="font-medium">{formatCurrency(row.amount)}</span>,
},
{
key: "paid",
header: "Paid",
cell: (row) => <span className="text-muted-foreground">{formatCurrency(row.paidAmount ?? 0)}</span>,
},
{
key: "balance",
header: "Balance",
cell: (row) => <span className="font-medium">{formatCurrency(row.balance ?? row.amount)}</span>,
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={statusTone(row.status)}>{row.status}</StatusBadge>,
},
{
key: "overdue",
header: "Days overdue",
className: "text-right",
cell: (row) => {
const d = daysOverdue(row)
return d > 0 ? <Badge variant={d > 7 ? "destructive" : "secondary"}>{d}d</Badge> : <span className="text-muted-foreground">—</span>
},
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => {
const balance = row.balance ?? row.amount
return (
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="View previous invoices"
onClick={(e) => {
e.stopPropagation()
void handleViewHistory(row)
}}
>
<HistoryIcon />
</Button>
}
/>
<TooltipContent>View previous invoices</TooltipContent>
</Tooltip>
{balance > 0 && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Add payment"
disabled={payLoadingId === row.customerInvoiceId}
onClick={(e) => {
e.stopPropagation()
void handleOpenPay(row)
}}
>
<CreditCardIcon />
</Button>
}
/>
<TooltipContent>Add payment</TooltipContent>
</Tooltip>
)}
</div>
)
},
},
]
if (!demo && invoices.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<PageHeader title="Customer Payments" description="Member invoices and recent payments" />
<Tabs defaultValue="invoices" className="gap-4">
<TabsList>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
<TabsTrigger value="approvals">Approvals</TabsTrigger>
</TabsList>
<TabsContent value="invoices" className="flex flex-col gap-4">
<div className={cn("grid grid-cols-2 gap-3", showTodayPayments ? "lg:grid-cols-5" : "lg:grid-cols-4")}>
<StatCard icon={ReceiptIcon} label="Invoices" value={rows.length} hint="All member invoices" />
<StatCard icon={BanknoteIcon} tone="blue" label="Total Billed" value={formatCurrency(totalBilled)} hint="All time" />
<button
type="button"
className="text-left"
onClick={() => setBucket((b) => (b === "done" ? "all" : "done"))}
>
<StatCard
icon={CircleCheckIcon}
tone="green"
label="Done payments"
value={paid.length}
hint={formatCurrency(paid.reduce((s, i) => s + i.amount, 0))}
className={bucket === "done" ? "ring-2 ring-emerald-500" : undefined}
/>
</button>
<button
type="button"
className="text-left"
onClick={() => setBucket((b) => (b === "overdue" ? "all" : "overdue"))}
>
<StatCard
icon={ClockIcon}
tone="amber"
label="Overdue payments"
value={outstanding.length}
hint={`${formatCurrency(outstandingBalance)} due`}
className={bucket === "overdue" ? "ring-2 ring-amber-500" : undefined}
/>
</button>
{showTodayPayments && (
<button
type="button"
className="text-left"
onClick={() => setBucket((b) => (b === "today" ? "all" : "today"))}
>
<StatCard
icon={CalendarCheckIcon}
tone="blue"
label="Today's invoices"
value={dueToday.length}
hint={formatCurrency(dueToday.reduce((s, i) => s + i.amount, 0))}
className={bucket === "today" ? "ring-2 ring-blue-500" : undefined}
/>
</button>
)}
</div>
<FilterBar
searchLabel="Search Invoices"
searchPlaceholder="Search by member or plan..."
searchValue={search}
onSearchChange={(v) => {
setSearch(v)
setPage(1)
}}
filters={[
{
key: "status",
label: "Status",
value: status,
placeholder: "All Statuses",
options: [
{ label: "All Statuses", value: "all" },
{ label: "Paid", value: "paid" },
{ label: "Partial", value: "partial" },
{ label: "Pending", value: "pending" },
{ label: "Failed", value: "failed" },
],
onChange: (v) => {
setStatus(v)
setPage(1)
},
},
]}
resultsShowing={pageRows.length}
resultsTotal={filtered.length}
onClear={() => {
setSearch("")
setStatus("all")
setBucket("all")
setPage(1)
}}
/>
<DataTable
columns={columns}
rows={pageRows}
rowKey={(row) => row.customerInvoiceId}
onRowClick={(row) => setSelected(row)}
emptyMessage="No member invoices yet."
renderMobileCard={(row) => {
const balance = row.balance ?? row.amount
return (
<div className="w-full text-left" onClick={() => setSelected(row)}>
<div className="flex items-center justify-between gap-2">
<div className="leading-tight">
<p className="font-medium">
{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.renewalDate ? formatDate(row.customerSubscription.renewalDate) : "—"}
</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="font-semibold">{formatCurrency(row.amount)}</span>
<StatusBadge status={statusTone(row.status)}>{row.status}</StatusBadge>
</div>
</div>
<div className="mt-1 flex items-center justify-between">
{balance > 0 ? (
<p className="text-xs text-muted-foreground">{formatCurrency(balance)} balance</p>
) : (
<span />
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="View previous invoices"
onClick={(e) => {
e.stopPropagation()
void handleViewHistory(row)
}}
>
<HistoryIcon />
</Button>
{balance > 0 && (
<Button
variant="outline"
size="icon-sm"
aria-label="Add payment"
disabled={payLoadingId === row.customerInvoiceId}
onClick={(e) => {
e.stopPropagation()
void handleOpenPay(row)
}}
>
<CreditCardIcon />
</Button>
)}
</div>
</div>
</div>
)
}}
/>
<PaginationBar
page={page}
pageCount={totalPages}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size)
setPage(1)
}}
rangeStart={filtered.length ? (page - 1) * pageSize + 1 : 0}
rangeEnd={Math.min(page * pageSize, filtered.length)}
total={filtered.length}
onPageChange={setPage}
/>
</TabsContent>
<TabsContent value="approvals">
<RenewalApprovalsView demo={demo} onActionComplete={() => invoices.reload()} />
</TabsContent>
</Tabs>
<DetailPanel
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
title="Invoice"
description={selected ? formatDate(selected.customerInvoiceDate) : undefined}
>
{selected && (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 py-1 text-sm">
<dt className="text-muted-foreground">Member</dt>
<dd>{selected.customerName ?? selected.customerSubscription?.customer?.cusName ?? "—"}</dd>
<dt className="text-muted-foreground">Plan</dt>
<dd>{selected.customerSubscription?.customerPlan?.planName ?? "—"}</dd>
<dt className="text-muted-foreground">Amount</dt>
<dd className="font-semibold">{formatCurrency(selected.amount)}</dd>
<dt className="text-muted-foreground">Paid</dt>
<dd>{formatCurrency(selected.paidAmount ?? 0)}</dd>
<dt className="text-muted-foreground">Balance</dt>
<dd className="font-medium">{formatCurrency(selected.balance ?? selected.amount)}</dd>
<dt className="text-muted-foreground">Status</dt>
<dd>
<StatusBadge status={statusTone(selected.status)}>{selected.status}</StatusBadge>
</dd>
<dt className="text-muted-foreground">Invoice date</dt>
<dd>{formatDateTime(selected.customerInvoiceDate)}</dd>
<dt className="text-muted-foreground">Subscription</dt>
<dd className="truncate text-xs">{selected.fkCustomerSubscriptionId}</dd>
</dl>
)}
{selected && (
<Button
className="mt-4 w-full"
variant="outline"
onClick={() => setReceiptInvoice(selected)}
>
<ReceiptIcon />
View Receipt
</Button>
)}
</DetailPanel>
<ReceiptPreviewDialog
invoice={receiptInvoice}
open={!!receiptInvoice}
onOpenChange={(o) => !o && setReceiptInvoice(null)}
demo={demo}
demoSubscriptions={demoSubs}
/>
<PaymentQuickActionsDialog
open={actionsOpen}
onOpenChange={setActionsOpen}
memberName={
actionsInvoice?.customerName ?? actionsInvoice?.customerSubscription?.customer?.cusName ?? undefined
}
amount={
suggestedInstallmentAmount ??
paySummary?.balance ??
(actionsInvoice?.balance ?? actionsInvoice?.amount ?? 0)
}
sendingLink={sendingLink}
onSendLink={() => void handleSendLink()}
onMarkPaid={handleMarkPaid}
/>
<RecordPaymentDialog
open={payOpen}
onOpenChange={setPayOpen}
onSubmit={handleRecordPayment}
balance={paySummary?.balance ?? 0}
allowSplit={paySummary?.allowSplitPayments ?? false}
suggestedAmount={suggestedInstallmentAmount}
/>
<DetailPanel
open={historyOpen}
onOpenChange={(open) => {
setHistoryOpen(open)
if (!open) setHistoryRows(null)
}}
title="Previous Invoices"
description={historyMemberName ?? undefined}
>
{historyLoading && <p className="py-4 text-sm text-muted-foreground">Loading…</p>}
{!historyLoading && historyRows && historyRows.length === 0 && (
<p className="py-4 text-sm text-muted-foreground">No previous invoices for this member.</p>
)}
{!historyLoading && historyRows && historyRows.length > 0 && (
<ul className="flex flex-col divide-y">
{historyRows.map((row) => (
<li key={row.customerInvoiceId}>
<button
type="button"
className="flex w-full items-start justify-between gap-3 py-3 text-left"
onClick={() => {
setHistoryOpen(false)
setSelected(row)
}}
>
<div className="flex flex-col gap-0.5 leading-tight">
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.customerPlan?.planName ?? "—"}
</p>
<p className="font-medium">
Invoiced {formatDate(row.customerInvoiceDate)}
</p>
<p className="text-xs text-muted-foreground">
Renews{" "}
{row.customerSubscription?.renewalDate
? formatDate(row.customerSubscription.renewalDate)
: "—"}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-sm font-medium">{formatCurrency(row.amount)}</span>
<StatusBadge status={statusTone(row.status)}>{row.status}</StatusBadge>
</div>
</button>
</li>
))}
</ul>
)}
</DetailPanel>
</div>
)
}
export { CustomerPaymentsView }
@@ -0,0 +1,217 @@
"use client"
import * as React from "react"
import { AlertCircle, Banknote, CalendarClock, Receipt, TrendingUp, UserPlus, Users } from "lucide-react"
import { getTodayAttendance } from "@/lib/api/attendance"
import {
getMonthlyRevenue,
getRecentPayments,
getStatSummary,
} from "@/lib/api/stats"
import {
DEMO_RECENT_PAYMENTS,
DEMO_REVENUE,
DEMO_STAT_SUMMARY,
DEMO_TODAY_ATTENDANCE,
} from "@/lib/demo/mock-data"
import { formatCurrency, formatDateTime } from "@/lib/format"
import { getPeriodRange, PERIOD_OPTIONS, type PeriodKey } from "@/lib/dashboard-period"
import { sortByNewest } from "@/lib/validation"
import { useFetch } from "@/lib/use-fetch"
import { useNavItems } from "@/hooks/use-nav-items"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CheckinList } from "@/components/hexa/checkin-list"
import { MemberTrendChartCard } from "@/components/hexa/member-trend-chart-card"
import { PageHeader } from "@/components/hexa/page-header"
import { RevenueChartCard } from "@/components/hexa/revenue-chart-card"
import { StatCard } from "@/components/hexa/stat-card"
import { StatusBadge } from "@/components/hexa/status-badge"
interface DashboardOverviewViewProps {
demo?: boolean
/** Sandbox-only: useNavItems() is disabled in demo mode, so demo can't derive isOwner itself. */
demoIsOwner?: boolean
}
function DashboardOverviewView({ demo = false, demoIsOwner = true }: DashboardOverviewViewProps) {
const { isOwner: liveIsOwner } = useNavItems(!demo)
const isOwner = demo ? demoIsOwner : liveIsOwner
const [period, setPeriod] = React.useState<PeriodKey>("30d")
const { from, to } = getPeriodRange(period)
const summary = useFetch(demo ? async () => DEMO_STAT_SUMMARY : () => getStatSummary(from, to))
const revenue = useFetch(demo ? async () => DEMO_REVENUE : () => getMonthlyRevenue(from, to))
const payments = useFetch(demo ? async () => DEMO_RECENT_PAYMENTS : getRecentPayments)
const today = useFetch(demo ? async () => DEMO_TODAY_ATTENDANCE : getTodayAttendance)
const mountedRef = React.useRef(false)
React.useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true
return
}
summary.reload()
revenue.reload()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [period])
const growthHint = (pct: number) =>
`${pct >= 0 ? "+" : ""}${pct.toFixed(1)}% vs previous period`
const latestCheckins = sortByNewest(
today.data ?? [],
(entry) => entry.eventTime
)
.slice(0, 5)
.map((entry) => ({
name: entry.customerName,
time: entry.eventTime,
eventType: entry.eventType ?? undefined,
}))
return (
<div className="flex flex-col gap-4">
<PageHeader title="Dashboard" description="Your gym at a glance" />
<Tabs value={period} onValueChange={(v) => setPeriod(v as PeriodKey)}>
<TabsList>
{PERIOD_OPTIONS.map((opt) => (
<TabsTrigger key={opt.value} value={opt.value}>
{opt.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div
className={cn(
"grid grid-cols-2 gap-3",
summary.data?.showTodayPayments ? "lg:grid-cols-5" : "lg:grid-cols-4"
)}
>
{summary.loading ? (
Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-20 rounded-xl sm:h-24" />
))
) : (
<>
<StatCard
icon={Users}
label="Total Members"
value={summary.data?.totalCustomerCount ?? 0}
hint="All time"
/>
<StatCard
icon={UserPlus}
tone="blue"
label="New Members"
value={summary.data?.thisMonthCustomerCount ?? 0}
hint={growthHint(summary.data?.customerGrowthPercentage ?? 0)}
/>
{isOwner ? (
<>
<StatCard
icon={Banknote}
tone="green"
label="Revenue"
value={formatCurrency(summary.data?.thisMonthRevenue ?? 0)}
hint={growthHint(summary.data?.revenueGrowthPercentage ?? 0)}
/>
<StatCard
icon={TrendingUp}
tone="purple"
label="Total Revenue"
value={formatCurrency(summary.data?.totalRevenue ?? 0)}
hint="All time"
/>
</>
) : (
<>
<StatCard
icon={CalendarClock}
tone="amber"
label="Expiring Soon"
value={summary.data?.expiringWithin7DaysCount ?? 0}
hint="Next 7 days"
/>
<StatCard
icon={AlertCircle}
tone="purple"
label="Overdue Members"
value={summary.data?.overdueCount ?? 0}
hint="Payment overdue"
/>
</>
)}
{summary.data?.showTodayPayments && (
<StatCard
icon={Receipt}
tone="blue"
label="Today's Payments"
value={formatCurrency(summary.data?.todayPaymentsTotal ?? 0)}
hint={`${summary.data?.todayPaymentsCount ?? 0} payment(s) today`}
/>
)}
</>
)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{isOwner ? (
<RevenueChartCard data={revenue.data} loading={revenue.loading} />
) : (
<MemberTrendChartCard data={revenue.data} loading={revenue.loading} />
)}
<CheckinList
entries={today.data ? latestCheckins : null}
loading={today.loading}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">Recent payments</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{payments.loading ? (
Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="my-1 h-12 rounded-lg" />
))
) : !payments.data || payments.data.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payments recorded yet.
</p>
) : (
payments.data.map((payment) => (
<div key={payment.attemptId} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1 leading-tight">
<p className="truncate text-sm font-medium">
{payment.customer?.cusName ?? "Unknown member"}
</p>
<p className="text-xs text-muted-foreground">
{payment.payDate ? formatDateTime(payment.payDate) : "—"}
</p>
</div>
<span className="text-sm font-semibold">
{formatCurrency(payment.payAmount)}
</span>
<StatusBadge
status={payment.payStatus?.toLowerCase() === "success" ? "success" : "info"}
>
{payment.payStatus ?? "—"}
</StatusBadge>
</div>
))
)}
</CardContent>
</Card>
</div>
)
}
export { DashboardOverviewView }
@@ -0,0 +1,286 @@
"use client"
import * as React from "react"
import { CameraIcon, RefreshCwIcon, UploadIcon } from "lucide-react"
import { toast } from "sonner"
import { setCustomerFacePhoto } from "@/lib/api/access-devices"
import {
FACE_HEIGHT,
FACE_WIDTH,
FacePhotoError,
normalizeFacePhoto,
type NormalizedFacePhoto,
} from "@/lib/face-photo"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
interface FacePhotoDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
customerId: string
memberName: string
onSaved?: () => void
}
type Mode = "choose" | "camera" | "preview"
function FacePhotoDialog({
open,
onOpenChange,
customerId,
memberName,
onSaved,
}: FacePhotoDialogProps) {
const [mode, setMode] = React.useState<Mode>("choose")
const [photo, setPhoto] = React.useState<NormalizedFacePhoto | null>(null)
const [error, setError] = React.useState<string | null>(null)
const [busy, setBusy] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const videoRef = React.useRef<HTMLVideoElement>(null)
const streamRef = React.useRef<MediaStream | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const stopCamera = React.useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
}, [])
// Unmount safety net: the camera must be released even if the tree goes away without
// the close handler running.
React.useEffect(() => stopCamera, [stopCamera])
// Reset on the close event rather than in an effect, so state changes stay in the
// event handler and don't cascade an extra render.
function handleOpenChange(next: boolean) {
if (!next) {
stopCamera()
setMode("choose")
setPhoto(null)
setError(null)
setBusy(false)
}
onOpenChange(next)
}
async function startCamera() {
setError(null)
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "user",
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
streamRef.current = stream
setMode("camera")
// The element only exists after the mode switch renders.
requestAnimationFrame(() => {
if (videoRef.current) {
videoRef.current.srcObject = stream
void videoRef.current.play()
}
})
} catch (err) {
const name = err instanceof Error ? err.name : ""
setError(
name === "NotAllowedError"
? "Camera access was blocked. Allow the camera for this site, or upload a photo instead."
: name === "NotFoundError"
? "No camera was found on this device. Upload a photo instead."
: "Could not start the camera. Upload a photo instead."
)
}
}
async function capture() {
if (!videoRef.current) return
setBusy(true)
setError(null)
try {
const normalized = await normalizeFacePhoto(videoRef.current)
stopCamera()
setPhoto(normalized)
setMode("preview")
} catch (err) {
setError(err instanceof FacePhotoError ? err.message : "Could not capture that frame.")
} finally {
setBusy(false)
}
}
async function handleFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
// Allow picking the same file again after a rejection.
event.target.value = ""
if (!file) return
setBusy(true)
setError(null)
try {
const normalized = await normalizeFacePhoto(file)
stopCamera()
setPhoto(normalized)
setMode("preview")
} catch (err) {
setError(
err instanceof FacePhotoError ? err.message : "Could not read that image file."
)
} finally {
setBusy(false)
}
}
async function save() {
if (!photo) return
setSaving(true)
setError(null)
try {
const result = await setCustomerFacePhoto(customerId, photo.base64)
if (result.queuedToDevices === 0) {
toast.warning("Photo saved, but no terminal is online", {
description: "It will not enrol until an access terminal reconnects.",
})
} else {
toast.success("Face photo sent to the access terminal", {
description: `Enrolling ${memberName}. The status updates here when the terminal confirms.`,
})
}
onSaved?.()
handleOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the face photo.")
} finally {
setSaving(false)
}
}
function retake() {
setPhoto(null)
setError(null)
setMode("choose")
}
return (
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Face photo</DialogTitle>
<DialogDescription>
Take or upload a clear, front-facing photo of {memberName} for the access
terminal. The photo is sent straight to the terminal and is not stored here.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div
className="relative mx-auto overflow-hidden rounded-lg border bg-muted"
style={{ width: 240, height: 320 }}
>
{mode === "camera" && (
<video
ref={videoRef}
playsInline
muted
className="size-full object-cover"
/>
)}
{mode === "preview" && photo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photo.previewUrl}
alt={`Face photo for ${memberName}`}
className="size-full object-cover"
/>
)}
{mode === "choose" && (
<div className="flex size-full flex-col items-center justify-center gap-2 p-4 text-center">
<CameraIcon className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Face the camera straight on, with even lighting and no hat or sunglasses.
</p>
</div>
)}
{busy && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70">
<Spinner className="size-6" />
</div>
)}
</div>
{photo && (
<p className="text-center text-xs text-muted-foreground">
{FACE_WIDTH}&times;{FACE_HEIGHT} &middot; {Math.round(photo.bytes / 1024)} KB
</p>
)}
{error && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFile}
/>
</div>
<DialogFooter className="gap-2 sm:justify-between">
{mode === "preview" ? (
<>
<Button variant="outline" onClick={retake} disabled={saving}>
<RefreshCwIcon />
Retake
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Spinner className="size-4" />}
Send to terminal
</Button>
</>
) : mode === "camera" ? (
<>
<Button variant="outline" onClick={retake} disabled={busy}>
Cancel
</Button>
<Button onClick={capture} disabled={busy}>
<CameraIcon />
Capture
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => fileRef.current?.click()} disabled={busy}>
<UploadIcon />
Upload photo
</Button>
<Button onClick={startCamera} disabled={busy}>
<CameraIcon />
Use camera
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { FacePhotoDialog }
+227
View File
@@ -0,0 +1,227 @@
"use client"
import * as React from "react"
import { ClockIcon, HeartIcon, LayersIcon, PlusIcon } from "lucide-react"
import { toast } from "sonner"
import {
createFeedPost,
deleteFeedPost,
getFeed,
likePost,
unlikePost,
} from "@/lib/api/feed"
import { formatDate } from "@/lib/format"
import { sortByNewest } from "@/lib/validation"
import type { CreateFeedInput, FeedPost } from "@/lib/models/feed"
import { useFetch } from "@/lib/use-fetch"
import { runMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { FilterBar } from "@/components/hexa/filter-bar"
import { InfoBanner } from "@/components/hexa/info-banner"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { PostCard } from "@/components/hexa/post-card"
import { StatCard } from "@/components/hexa/stat-card"
import { CreateFeedDialog } from "@/components/hexa/views/create-feed-dialog"
const DEMO_POSTS: FeedPost[] = [
{ feedPostId: "demo-1", caption: "New Workout Plan Feature Added!", description: "We've added new customizable workout plans. Check it out!", userId: "u1", isActive: true, createdAt: "2026-05-19", likeCount: 12 },
{ feedPostId: "demo-2", caption: "Gym Closed for Maintenance", description: "Closed this Sunday 8 AM – 12 PM for equipment maintenance.", userId: "u1", isActive: true, createdAt: "2026-05-12", likeCount: 4 },
]
const DEMO_POST_IMAGES: Record<string, string> = {
"demo-1": "https://images.unsplash.com/photo-1534438327276-14e5300c3a48?auto=format&fit=crop&w=1200&q=80",
"demo-2": "https://images.unsplash.com/photo-1581009146145-b5ef050c2e1e?auto=format&fit=crop&w=1200&q=80",
}
function imageUrlFor(post: FeedPost): string | undefined {
const doc = post.document
if (!doc?.data) return undefined
const ext = (doc.fileType ?? "").replace(".", "").toLowerCase() || "jpeg"
return `data:image/${ext === "jpg" ? "jpeg" : ext};base64,${doc.data}`
}
interface FeedViewProps {
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function FeedView({ demo = false }: FeedViewProps) {
const feedFetch = useFetch(demo ? async () => DEMO_POSTS : getFeed)
const [demoPosts, setDemoPosts] = React.useState<FeedPost[]>(DEMO_POSTS)
const posts = React.useMemo(
() => sortByNewest(demo ? demoPosts : (feedFetch.data ?? []), (p) => p.createdAt),
[demo, demoPosts, feedFetch.data]
)
const [search, setSearch] = React.useState("")
const [dialogOpen, setDialogOpen] = React.useState(false)
const [deleteTarget, setDeleteTarget] = React.useState<FeedPost | null>(null)
const [deleting, setDeleting] = React.useState(false)
const [likedMap, setLikedMap] = React.useState<Record<string, boolean>>({})
const [likeDelta, setLikeDelta] = React.useState<Record<string, number>>({})
const filtered = posts.filter(
(p) =>
!search ||
(p.caption ?? "").toLowerCase().includes(search.toLowerCase()) ||
(p.description ?? "").toLowerCase().includes(search.toLowerCase())
)
const totalLikes = posts.reduce((sum, p) => sum + p.likeCount, 0)
const activeCount = posts.filter((p) => p.isActive).length
async function handleCreate(input: CreateFeedInput) {
if (demo) {
setDemoPosts((prev) => [
{ feedPostId: crypto.randomUUID(), caption: input.caption, description: input.description, userId: "u1", isActive: true, createdAt: new Date().toISOString(), likeCount: 0 },
...prev,
])
toast.success("Post published")
return
}
// No try/catch here: CreateFeedDialog wraps this call with run(), which already
// toasts the real error and keeps the dialog open — a second catch here would
// double-toast.
await createFeedPost(input)
toast.success("Post published")
feedFetch.reload()
}
async function handleDelete() {
if (!deleteTarget) return
if (demo) {
setDemoPosts((prev) => prev.filter((p) => p.feedPostId !== deleteTarget.feedPostId))
toast("Post deleted")
setDeleteTarget(null)
return
}
setDeleting(true)
try {
await runMutation(() => deleteFeedPost(deleteTarget.feedPostId))
toast("Post deleted")
setDeleteTarget(null)
feedFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setDeleting(false)
}
}
async function toggleLike(post: FeedPost) {
const id = post.feedPostId
const currentlyLiked = likedMap[id] ?? false
// Optimistic flip; revert on failure.
setLikedMap((m) => ({ ...m, [id]: !currentlyLiked }))
setLikeDelta((d) => ({ ...d, [id]: (d[id] ?? 0) + (currentlyLiked ? -1 : 1) }))
if (demo) return
try {
await runMutation(() => (currentlyLiked ? unlikePost(id) : likePost(id)))
} catch {
// runMutation() already toasted the real error; revert the optimistic flip.
setLikedMap((m) => ({ ...m, [id]: currentlyLiked }))
setLikeDelta((d) => ({ ...d, [id]: (d[id] ?? 0) + (currentlyLiked ? 1 : -1) }))
}
}
if (!demo && feedFetch.loading) {
return <PageSkeleton variant="stats-cards" />
}
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Feed Section"
description="Manage and monitor all feed items posted for members"
action={
<Button onClick={() => setDialogOpen(true)}>
<PlusIcon />
Add Feed Item
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={LayersIcon} tone="primary" label="Total Feed Items" value={posts.length} hint="All time posts" />
<StatCard icon={ClockIcon} tone="green" label="Published Items" value={activeCount} hint="Visible to members" />
<StatCard icon={ClockIcon} tone="amber" label="Inactive Items" value={posts.length - activeCount} hint="Hidden posts" />
<StatCard icon={HeartIcon} tone="purple" label="Total Likes" value={totalLikes} hint="Across all posts" />
</div>
<FilterBar
searchLabel="Search Feed Items"
searchPlaceholder="Search by title or description..."
searchValue={search}
onSearchChange={setSearch}
resultsShowing={filtered.length}
resultsTotal={posts.length}
onClear={() => setSearch("")}
/>
{filtered.length === 0 ? (
<div className="rounded-xl border bg-card py-16 text-center text-sm text-muted-foreground">
{posts.length === 0
? "No posts yet. Share your first update with your members."
: "No feed items match your search."}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((post) => (
<PostCard
key={post.feedPostId}
imageUrl={imageUrlFor(post) ?? (demo ? DEMO_POST_IMAGES[post.feedPostId] : undefined)}
title={post.caption ?? "Untitled post"}
description={post.description ?? ""}
published={post.isActive}
likes={Math.max(post.likeCount + (likeDelta[post.feedPostId] ?? 0), 0)}
liked={likedMap[post.feedPostId] ?? false}
onToggleLike={() => toggleLike(post)}
createdAt={formatDate(post.createdAt)}
author={{ name: "You", role: "Gym Owner" }}
onDelete={() => setDeleteTarget(post)}
/>
))}
</div>
)}
<InfoBanner>
Share announcements, updates, and motivation with your members through the feed section.
</InfoBanner>
<CreateFeedDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this post?</AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.caption ?? "This post"}&quot; will be removed from the member feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting && <Spinner className="size-4" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export { FeedView }
@@ -0,0 +1,653 @@
"use client"
import * as React from "react"
import {
Banknote,
CheckIcon,
CircleCheckIcon,
ClockIcon,
DumbbellIcon,
EyeIcon,
PencilIcon,
PlusIcon,
UsersIcon,
XIcon,
} from "lucide-react"
import { toast } from "sonner"
import {
createGymPlan,
getGymPlans,
setGymPlanStatus,
updateGymPlan,
} from "@/lib/api/gym-plans"
import { getPlaces } from "@/lib/api/places"
import type { GymPlan } from "@/lib/models/gym-plan"
import { sortByNewest } from "@/lib/validation"
import { useFetch } from "@/lib/use-fetch"
import { 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 { Spinner } from "@/components/ui/spinner"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { DetailPanel } from "@/components/hexa/detail-panel"
import { FilterBar } from "@/components/hexa/filter-bar"
import { InfoBanner } from "@/components/hexa/info-banner"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { PaginationBar } from "@/components/hexa/pagination-bar"
import { StatCard } from "@/components/hexa/stat-card"
import { StatusBadge } from "@/components/hexa/status-badge"
import { AddGymPlanDialog, type GymPlanFormValues } from "@/components/hexa/views/add-gym-plan-dialog"
function formatDuration(days: number) {
if (days % 30 === 0 || days === 365) {
const months = days === 365 ? 12 : days / 30
return `${months} month${months > 1 ? "s" : ""}`
}
return `${days} days`
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })
}
/** Human duration for the table cell — days for Rolling, a date range for FixedRange. */
function planDurationLabel(plan: GymPlan) {
if (plan.durationMode === "FixedRange" && plan.validFrom && plan.validTo) {
return `${formatDate(plan.validFrom)} - ${formatDate(plan.validTo)}`
}
return formatDuration(plan.durationInDays)
}
/** "Individual" / "Couple" / "Group of N" from scope + member limit. */
function planScopeLabel(plan: GymPlan) {
if (plan.planScope !== "Group") return "Individual"
if (plan.memberLimit === 2) return "Couple"
return `Group of ${plan.memberLimit}`
}
function demoPlan(partial: Partial<GymPlan> & Pick<GymPlan, "customerPlanId" | "planName" | "planPrice">): GymPlan {
return {
fkClientId: "demo",
clientName: null,
planDescription: null,
durationInDays: 30,
planScope: "Individual",
memberLimit: 1,
durationMode: "Rolling",
validFrom: null,
validTo: null,
allowSplitPayments: false,
installmentCount: 1,
hasOnboardingFee: false,
onboardingFeeAmount: 0,
isActive: true,
createdAt: "2026-07-01T09:00:00.000Z",
placeIds: [],
placeNames: [],
...partial,
}
}
const DEMO_PLANS: GymPlan[] = [
demoPlan({ customerPlanId: "demo-1", planName: "Basic Monthly", planDescription: "Gym floor access only", durationInDays: 30, planPrice: 6000 }),
demoPlan({ customerPlanId: "demo-2", planName: "Couple Quarterly", planDescription: "Gym + group classes for two", durationInDays: 90, planPrice: 24000, planScope: "Group", memberLimit: 2, allowSplitPayments: true, installmentCount: 3 }),
demoPlan({ customerPlanId: "demo-3", planName: "Family Season Pass", planDescription: "Whole-family access", durationInDays: 0, planPrice: 40000, planScope: "Group", memberLimit: 5, durationMode: "FixedRange", validFrom: "2026-08-01", validTo: "2026-12-31" }),
]
interface GymPlansViewProps {
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function GymPlansView({ demo = false }: GymPlansViewProps) {
const plansFetch = useFetch(demo ? async () => DEMO_PLANS : () => getGymPlans(true))
const placesFetch = useFetch(demo ? async () => [] : () => getPlaces(false))
const places = placesFetch.data ?? []
const [demoPlans, setDemoPlans] = React.useState<GymPlan[]>(DEMO_PLANS)
const plans = React.useMemo(
() => sortByNewest(demo ? demoPlans : (plansFetch.data ?? []), (p) => p.createdAt),
[demo, demoPlans, plansFetch.data]
)
const [search, setSearch] = React.useState("")
const [status, setStatus] = React.useState("all")
const [duration, setDuration] = React.useState("all")
const [placeFilter, setPlaceFilter] = React.useState("all")
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(10)
const [dialogOpen, setDialogOpen] = React.useState(false)
const [dialogMode, setDialogMode] = React.useState<"create" | "edit">("create")
const [editTarget, setEditTarget] = React.useState<GymPlan | null>(null)
const [viewTarget, setViewTarget] = React.useState<GymPlan | null>(null)
// Both directions confirm first -- the row's only remaining action changes what
// members can sign up for, so neither should fire on a single stray click.
const [statusTarget, setStatusTarget] = React.useState<GymPlan | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
const filtered = plans.filter((p) => {
const matchesSearch =
!search ||
p.planName.toLowerCase().includes(search.toLowerCase()) ||
(p.planDescription ?? "").toLowerCase().includes(search.toLowerCase()) ||
String(p.planPrice).includes(search)
const matchesStatus =
status === "all" || (status === "active" ? p.isActive : !p.isActive)
const matchesDuration =
duration === "all" ||
(duration === "fixed" ? p.durationMode === "FixedRange" : String(p.durationInDays) === duration)
const matchesPlace =
placeFilter === "all" ||
(placeFilter === "unrestricted" ? p.placeIds.length === 0 : p.placeIds.includes(placeFilter))
return matchesSearch && matchesStatus && matchesDuration && matchesPlace
})
const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1)
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize)
const activeCount = plans.filter((p) => p.isActive).length
const avgDurationDays = plans.length
? Math.round(plans.reduce((sum, p) => sum + p.durationInDays, 0) / plans.length)
: 0
const avgPrice = plans.length
? Math.round(plans.reduce((sum, p) => sum + p.planPrice, 0) / plans.length)
: 0
/** Translate the dialog's UI values into the API input + a demo-mode GymPlan patch. */
function mapValues(values: GymPlanFormValues) {
const isGroup = values.scope !== "Individual"
const memberLimit = values.scope === "Couple" ? 2 : values.scope === "Family" ? Number(values.memberLimit) || 2 : 1
const isRolling = values.durationMode === "Rolling"
const allowSplit = values.allowSplit
const installmentCount = allowSplit ? Number(values.installmentCount) || undefined : undefined
const apiInput = {
planName: values.name.trim(),
planDescription: values.description.trim() || undefined,
planPrice: Number(values.price),
hasOnboardingFee: values.hasOnboardingFee,
onboardingFeeAmount: values.hasOnboardingFee ? Number(values.onboardingFeeAmount) : 0,
planScope: (isGroup ? "Group" : "Individual") as GymPlan["planScope"],
memberLimit,
durationMode: values.durationMode,
durationInDays: isRolling ? Number(values.duration) : undefined,
validFrom: !isRolling ? values.validFrom : undefined,
validTo: !isRolling ? values.validTo : undefined,
allowSplitPayments: allowSplit,
installmentCount,
placeIds: values.placeIds,
}
const planPatch: Partial<GymPlan> = {
planName: apiInput.planName,
planDescription: apiInput.planDescription ?? null,
planPrice: apiInput.planPrice,
hasOnboardingFee: apiInput.hasOnboardingFee,
onboardingFeeAmount: apiInput.onboardingFeeAmount,
planScope: apiInput.planScope,
memberLimit,
durationMode: values.durationMode,
durationInDays: isRolling ? Number(values.duration) : 0,
validFrom: !isRolling ? values.validFrom : null,
validTo: !isRolling ? values.validTo : null,
allowSplitPayments: allowSplit,
installmentCount: installmentCount ?? 1,
placeIds: values.placeIds,
placeNames: places
.filter((p) => values.placeIds.includes(p.placeId))
.map((p) => p.name),
}
return { apiInput, planPatch }
}
async function handleCreate(values: GymPlanFormValues) {
const { apiInput, planPatch } = mapValues(values)
if (demo) {
setDemoPlans((prev) => [demoPlan({ customerPlanId: crypto.randomUUID(), planName: apiInput.planName, planPrice: apiInput.planPrice, ...planPatch }), ...prev])
toast.success("Gym plan created", { description: `"${values.name}" was added.` })
return
}
await createGymPlan(apiInput)
toast.success("Gym plan created", { description: `"${values.name}" was added.` })
plansFetch.reload()
}
async function handleEdit(values: GymPlanFormValues) {
if (!editTarget) return
const { apiInput, planPatch } = mapValues(values)
if (demo) {
setDemoPlans((prev) =>
prev.map((p) => (p.customerPlanId === editTarget.customerPlanId ? { ...p, ...planPatch } : p))
)
toast.success("Gym plan updated")
return
}
await updateGymPlan(editTarget.customerPlanId, { ...apiInput, isActive: editTarget.isActive })
toast.success("Gym plan updated")
plansFetch.reload()
}
async function applyStatus(plan: GymPlan, isActive: boolean) {
if (demo) {
setDemoPlans((prev) =>
prev.map((p) => (p.customerPlanId === plan.customerPlanId ? { ...p, isActive } : p))
)
toast.success(isActive ? "Gym plan activated" : "Gym plan deactivated", {
description: `"${plan.planName}" is now ${isActive ? "active" : "inactive"}.`,
})
return
}
setStatusPendingId(plan.customerPlanId)
try {
await runMutation(() => setGymPlanStatus(plan.customerPlanId, isActive))
toast.success(isActive ? "Gym plan activated" : "Gym plan deactivated", {
description: `"${plan.planName}" is now ${isActive ? "active" : "inactive"}.`,
})
plansFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
async function confirmStatusChange() {
if (!statusTarget) return
const plan = statusTarget
setStatusTarget(null)
await applyStatus(plan, !plan.isActive)
}
function startEdit(plan: GymPlan) {
setDialogMode("edit")
setEditTarget(plan)
setDialogOpen(true)
}
const columns: DataTableColumn<GymPlan>[] = [
{
key: "name",
header: "Plan Name",
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>
<span className="font-medium">{row.planName}</span>
</div>
),
},
{
key: "type",
header: "Type",
cell: (row) => (
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={row.planScope === "Group" ? "default" : "outline"}>{planScopeLabel(row)}</Badge>
{row.allowSplitPayments && (
<Badge variant="outline">{row.installmentCount} installments</Badge>
)}
</div>
),
},
{
key: "places",
header: "Places",
cell: (row) => (
<div className="flex flex-wrap items-center gap-1.5">
{row.placeNames.length ? (
row.placeNames.map((name) => (
<Badge key={name} variant="outline">
{name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">Unrestricted</span>
)}
</div>
),
},
{
key: "duration",
header: "Duration",
cell: (row) => <Badge variant="outline">{planDurationLabel(row)}</Badge>,
},
{
key: "price",
header: "Price",
cell: (row) => (
<span className="font-medium">Rs. {row.planPrice.toLocaleString()}</span>
),
},
{
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="View plan" onClick={() => setViewTarget(row)}>
<EyeIcon />
</Button>
}
/>
<TooltipContent>View plan</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Edit plan" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit plan</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate plan" : "Activate plan"}
disabled={statusPendingId === row.customerPlanId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.customerPlanId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
) : (
<XIcon className="text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate plan" : "Activate plan"}</TooltipContent>
</Tooltip>
</div>
),
},
]
if (plansFetch.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Gym Plan Management"
description="Manage and monitor all registered gym plans"
action={
<Button
onClick={() => {
setDialogMode("create")
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Gym Plan
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={UsersIcon} tone="blue" label="Total Gym Plans" value={plans.length} hint="All gym plans" />
<StatCard icon={CircleCheckIcon} tone="green" label="Active Gym Plans" value={activeCount} hint="Currently active" />
<StatCard icon={ClockIcon} tone="amber" label="Avg. Duration" value={avgDurationDays} hint="Days" />
<StatCard icon={Banknote} tone="purple" label="Avg. Price" value={avgPrice.toLocaleString()} hint="LKR" />
</div>
<FilterBar
searchLabel="Search Gym Plans"
searchPlaceholder="Search by plan name, description or price..."
searchValue={search}
onSearchChange={(v) => {
setSearch(v)
setPage(1)
}}
filters={[
{
key: "status",
label: "Status",
value: status,
placeholder: "All Statuses",
options: [
{ label: "All Statuses", value: "all" },
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
],
onChange: (v) => {
setStatus(v)
setPage(1)
},
},
{
key: "duration",
label: "Duration",
value: duration,
placeholder: "All Durations",
options: [
{ label: "All Durations", value: "all" },
{ label: "1 month", value: "30" },
{ label: "3 months", value: "90" },
{ label: "6 months", value: "180" },
{ label: "12 months", value: "365" },
{ label: "Fixed range", value: "fixed" },
],
onChange: (v) => {
setDuration(v)
setPage(1)
},
},
{
key: "place",
label: "Place",
value: placeFilter,
placeholder: "All Places",
options: [
{ label: "All Places", value: "all" },
{ label: "Unrestricted", value: "unrestricted" },
...places.map((p) => ({ label: p.name, value: p.placeId })),
],
onChange: (v) => {
setPlaceFilter(v)
setPage(1)
},
},
]}
resultsShowing={pageRows.length}
resultsTotal={filtered.length}
onClear={() => {
setSearch("")
setStatus("all")
setDuration("all")
setPlaceFilter("all")
setPage(1)
}}
/>
<DataTable
columns={columns}
rows={pageRows}
rowKey={(row) => row.customerPlanId}
emptyMessage="No gym plans yet. Create your first plan 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.planName}</p>
<p className="text-sm text-muted-foreground">{row.planDescription ?? "—"}</p>
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={row.planScope === "Group" ? "default" : "outline"}>{planScopeLabel(row)}</Badge>
{row.allowSplitPayments && <Badge variant="outline">{row.installmentCount} installments</Badge>}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{row.placeNames.length ? (
row.placeNames.map((name) => (
<Badge key={name} variant="outline">
{name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">Unrestricted</span>
)}
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<div className="flex items-center gap-3 text-sm">
<Badge variant="outline">{planDurationLabel(row)}</Badge>
<span className="font-medium">Rs. {row.planPrice.toLocaleString()}</span>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="View plan" onClick={() => setViewTarget(row)}>
<EyeIcon />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Edit plan" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate plan" : "Activate plan"}
disabled={statusPendingId === row.customerPlanId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.customerPlanId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
) : (
<XIcon className="text-muted-foreground" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<PaginationBar
page={page}
pageCount={totalPages}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size)
setPage(1)
}}
rangeStart={filtered.length ? (page - 1) * pageSize + 1 : 0}
rangeEnd={Math.min(page * pageSize, filtered.length)}
total={filtered.length}
onPageChange={setPage}
/>
<InfoBanner>
Create and manage gym plans to offer different membership options to your customers.
</InfoBanner>
<DetailPanel open={!!viewTarget} onOpenChange={(open) => !open && setViewTarget(null)} title={viewTarget?.planName ?? "Membership plan"} description="Plan details">
{viewTarget && <div className="grid grid-cols-2 gap-4"><div><p className="text-xs text-muted-foreground">Plan type</p><p className="mt-1 font-medium">{planScopeLabel(viewTarget)}</p></div><div><p className="text-xs text-muted-foreground">Price</p><p className="mt-1 font-medium">Rs. {viewTarget.planPrice.toLocaleString()}</p></div><div><p className="text-xs text-muted-foreground">Duration</p><p className="mt-1 font-medium">{planDurationLabel(viewTarget)}</p></div><div><p className="text-xs text-muted-foreground">Status</p><p className="mt-1"><StatusBadge status={viewTarget.isActive ? "success" : "neutral"}>{viewTarget.isActive ? "Active" : "Inactive"}</StatusBadge></p></div><div className="col-span-2"><p className="text-xs text-muted-foreground">Description</p><p className="mt-1 text-sm">{viewTarget.planDescription ?? "No description"}</p></div><div className="col-span-2"><p className="text-xs text-muted-foreground">Places</p><p className="mt-1 flex flex-wrap gap-1.5">{viewTarget.placeNames.length ? viewTarget.placeNames.map((name) => <Badge key={name} variant="outline">{name}</Badge>) : <span className="text-sm text-muted-foreground">Unrestricted (every terminal)</span>}</p></div>{viewTarget.allowSplitPayments && viewTarget.installmentCount && <div className="col-span-2 rounded-lg bg-muted/50 p-3"><p className="text-sm font-medium">Installment schedule</p><div className="mt-2 grid gap-2 text-sm">{Array.from({ length: viewTarget.installmentCount }, (_, index) => <div key={index} className="flex items-center justify-between border-b pb-2 last:border-0 last:pb-0"><span>Installment {index + 1}</span><span className="font-medium">Rs. {(viewTarget.planPrice / viewTarget.installmentCount).toLocaleString()}</span><StatusBadge status={index === 0 ? "success" : "warning"}>{index === 0 ? "Paid" : "Pending"}</StatusBadge></div>)}</div></div>}</div>}
</DetailPanel>
<AddGymPlanDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
mode={dialogMode}
places={places}
initialValues={
dialogMode === "edit" && editTarget
? {
name: editTarget.planName,
description: editTarget.planDescription ?? "",
scope:
editTarget.planScope !== "Group"
? "Individual"
: editTarget.memberLimit === 2
? "Couple"
: "Family",
memberLimit: String(editTarget.memberLimit),
durationMode: editTarget.durationMode,
duration: editTarget.durationInDays ? String(editTarget.durationInDays) : "",
validFrom: editTarget.validFrom ? editTarget.validFrom.slice(0, 10) : "",
validTo: editTarget.validTo ? editTarget.validTo.slice(0, 10) : "",
price: String(editTarget.planPrice),
hasOnboardingFee: editTarget.hasOnboardingFee,
onboardingFeeAmount: editTarget.hasOnboardingFee ? String(editTarget.onboardingFeeAmount) : "",
allowSplit: editTarget.allowSplitPayments,
installmentCount: editTarget.allowSplitPayments ? String(editTarget.installmentCount) : "",
placeIds: editTarget.placeIds,
}
: undefined
}
/>
<AlertDialog open={!!statusTarget} onOpenChange={(open) => !open && setStatusTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{statusTarget?.isActive ? "Deactivate this gym plan?" : "Activate this gym plan?"}
</AlertDialogTitle>
<AlertDialogDescription>
{statusTarget?.isActive ? (
<>
&quot;{statusTarget?.planName}&quot; will stop being offered to new members.
Members already on this plan keep their subscription, and you can reactivate
it at any time.
</>
) : (
<>
&quot;{statusTarget?.planName}&quot; will be offered to new members again and
will show up wherever plans can be selected.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmStatusChange}>
{statusTarget?.isActive ? "Deactivate" : "Activate"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export { GymPlansView }
@@ -0,0 +1,470 @@
"use client"
import * as React from "react"
import { DownloadIcon, ImageIcon, PlusIcon, QrCodeIcon, SaveIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import QRCode from "qrcode"
import {
getGymLogo,
getMyProfile,
parseFacilities,
updateGymProfile,
uploadGymLogo,
} from "@/lib/api/profile"
import { DEMO_PROFILE } from "@/lib/demo/mock-data"
import type { GymEquipment } from "@/lib/models/profile"
import { useFetch } from "@/lib/use-fetch"
import { runMutation } from "@/lib/use-mutation"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { ChangeContactDialog } from "@/components/hexa/views/change-contact-dialog"
/** Common gym equipment presets for the dropdown; owners can also type a custom value. */
const EQUIPMENT_OPTIONS = [
"Treadmill",
"Exercise bike",
"Elliptical",
"Rowing machine",
"Dumbbell set",
"Barbell",
"Bench press",
"Squat rack",
"Smith machine",
"Cable machine",
"Leg press",
"Lat pulldown",
"Kettlebell",
"Battle ropes",
"Yoga mat",
]
interface GymProfileViewProps {
/** Sandbox mode uses editable local gym details and equipment. */
demo?: boolean
}
export function GymProfileView({ demo = false }: GymProfileViewProps) {
const profileFetch = useFetch(demo ? async () => DEMO_PROFILE : getMyProfile)
const logoFetch = useFetch(demo ? async () => ({ logo: null }) : getGymLogo)
const [businessName, setBusinessName] = React.useState("")
const [description, setDescription] = React.useState("")
const [address, setAddress] = React.useState("")
const [city, setCity] = React.useState("")
const [hasAc, setHasAc] = React.useState(false)
const [equipment, setEquipment] = React.useState<GymEquipment[]>([])
const [saving, setSaving] = React.useState(false)
const [uploadingLogo, setUploadingLogo] = React.useState(false)
const [gymQrDataUrl, setGymQrDataUrl] = React.useState<string | null>(null)
const [contactDialog, setContactDialog] = React.useState<"email" | "phone" | null>(null)
const fileInputRef = React.useRef<HTMLInputElement>(null)
// Hydrate the form from the profile the first time it loads (render-phase sync,
// keyed on clientId so it doesn't clobber the user's in-progress edits on reload).
const profile = profileFetch.data
React.useEffect(() => {
if (!profile?.clientId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear the derived QR if the profile is removed
setGymQrDataUrl(null)
return
}
let cancelled = false
void QRCode.toDataURL(`hexafitness://gym/${profile.clientId}`, {
width: 640,
margin: 2,
color: { dark: "#111827", light: "#FFFFFF" },
errorCorrectionLevel: "M",
}).then((url) => {
if (!cancelled) setGymQrDataUrl(url)
}).catch(() => {
if (!cancelled) setGymQrDataUrl(null)
})
return () => { cancelled = true }
}, [profile?.clientId])
const [hydratedId, setHydratedId] = React.useState<string | null>(null)
if (profile && profile.clientId !== hydratedId) {
setHydratedId(profile.clientId)
const business = profile.clientBusinesses?.[0]
const facilities = parseFacilities(business?.facilities)
setBusinessName(business?.businessName ?? profile.clientName ?? "")
setDescription(business?.description ?? "")
setAddress(business?.address ?? "")
setCity(business?.city ?? "")
setHasAc(facilities.hasAc)
setEquipment(facilities.equipment)
}
function addEquipment() {
setEquipment((eq) => [...eq, { name: "", count: 1 }])
}
function updateEquipment(index: number, patch: Partial<GymEquipment>) {
setEquipment((eq) => eq.map((row, i) => (i === index ? { ...row, ...patch } : row)))
}
function removeEquipment(index: number) {
setEquipment((eq) => eq.filter((_, i) => i !== index))
}
async function handleSave() {
setSaving(true)
try {
if (demo) {
setEquipment((current) =>
current
.filter((item) => item.name.trim())
.map((item) => ({
name: item.name.trim(),
count: Math.max(0, Math.trunc(item.count) || 0),
}))
)
toast.success("Demo gym profile saved.")
return
}
const result = await runMutation(() =>
updateGymProfile({
businessName: businessName.trim(),
description: description.trim(),
address: address.trim(),
city: city.trim(),
hasAc,
equipment: equipment
.filter((e) => e.name.trim())
.map((e) => ({ name: e.name.trim(), count: Math.max(0, Math.trunc(e.count) || 0) })),
})
)
if (result.facilities) {
setHasAc(result.facilities.hasAc)
setEquipment(result.facilities.equipment)
}
toast.success("Gym profile saved")
profileFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setSaving(false)
}
}
async function handleLogoSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) {
setUploadingLogo(true)
try {
if (demo) {
logoFetch.mutate(() => ({ logo: URL.createObjectURL(file) }))
toast.success("Demo logo updated.")
return
}
await runMutation(() => uploadGymLogo(file))
toast.success("Logo updated")
logoFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setUploadingLogo(false)
}
}
// Reset so selecting the same file again re-triggers change.
if (fileInputRef.current) fileInputRef.current.value = ""
}
if (profileFetch.loading) {
return <PageSkeleton variant="stats-table" />
}
const logoUrl = logoFetch.data?.logo ?? null
function openContactDialog(type: "email" | "phone") {
if (demo) {
toast.info(`Contact verification is simulated in the demo (${type}).`)
return
}
setContactDialog(type)
}
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Gym Profile"
description="Your business details and gym facilities"
action={
<Button onClick={handleSave} disabled={saving}>
{saving ? <Spinner className="size-4" /> : <SaveIcon />}
Save Changes
</Button>
}
/>
{/* Account basics — phone/email editing lives in the Account settings section. */}
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Your gym owner account details</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2 text-sm">
<div>
<p className="text-muted-foreground">Owner / Gym name</p>
<p>{profile?.clientName ?? "—"}</p>
</div>
</div>
<div className="flex items-center justify-between gap-2 text-sm">
<div>
<p className="text-muted-foreground">Phone</p>
<p>{profile?.clientPhone ?? "—"}</p>
</div>
<Button variant="outline" size="sm" onClick={() => openContactDialog("phone")}>
Change
</Button>
</div>
<div className="flex items-center justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="text-muted-foreground">Email</p>
<p className="truncate">{profile?.clientEmail ?? "—"}</p>
</div>
<Button variant="outline" size="sm" onClick={() => openContactDialog("email")}>
Change
</Button>
</div>
</CardContent>
</Card>
<ChangeContactDialog
open={contactDialog !== null}
onOpenChange={(o) => !o && setContactDialog(null)}
type={contactDialog ?? "email"}
currentValue={contactDialog === "phone" ? profile?.clientPhone : profile?.clientEmail}
onChanged={() => profileFetch.reload()}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><QrCodeIcon className="size-5" />Customer onboarding QR</CardTitle>
<CardDescription>
Customers scan this unique code in the mobile app to request onboarding at your gym.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<div className="flex size-56 items-center justify-center overflow-hidden rounded-xl border bg-white p-3">
{gymQrDataUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={gymQrDataUrl} alt={`${profile?.clientName ?? "Gym"} onboarding QR code`} className="size-full" />
) : (
<Spinner className="size-7" />
)}
</div>
<div className="flex flex-1 flex-col gap-3 text-center sm:text-left">
<div>
<p className="font-medium">{(profile?.clientName ?? businessName) || "Your gym"}</p>
<p className="mt-1 text-sm text-muted-foreground">
Display this code at reception. Scanning it sends a pending request for your approval; it does not create a membership immediately.
</p>
</div>
<Button
variant="outline"
className="self-center sm:self-start"
disabled={!gymQrDataUrl}
onClick={() => {
if (!gymQrDataUrl) return
const link = document.createElement("a")
link.href = gymQrDataUrl
link.download = `${(profile?.clientName ?? "gym").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-onboarding-qr.png`
link.click()
}}
>
<DownloadIcon /> Download QR
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Logo</CardTitle>
<CardDescription>Shown across your gym&apos;s app and receipts</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-4">
<div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-muted ring-1 ring-foreground/10">
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt="Gym logo" className="size-full object-cover" />
) : (
<ImageIcon className="size-7 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-1.5">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleLogoSelect}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingLogo}
>
{uploadingLogo ? <Spinner className="size-4" /> : <ImageIcon />}
{logoUrl ? "Change logo" : "Upload logo"}
</Button>
<p className="text-xs text-muted-foreground">PNG, JPG or WEBP · up to 5MB</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Business details</CardTitle>
<CardDescription>How your gym appears to members</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="gym-name">Business name</Label>
<Input
id="gym-name"
placeholder="e.g. Iron Temple Fitness"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="gym-desc">Description</Label>
<Textarea
id="gym-desc"
placeholder="Tell members what makes your gym great..."
maxLength={500}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="gym-address">Address</Label>
<Input
id="gym-address"
placeholder="Street address"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="gym-city">City</Label>
<Input
id="gym-city"
placeholder="City"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Facilities & equipment</CardTitle>
<CardDescription>Air conditioning and equipment inventory</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Air conditioned</p>
<p className="text-xs text-muted-foreground">Is your gym floor AC?</p>
</div>
<Switch checked={hasAc} onCheckedChange={setHasAc} />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label>Equipment</Label>
<Button type="button" variant="outline" size="sm" onClick={addEquipment}>
<PlusIcon />
Add equipment
</Button>
</div>
{equipment.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
No equipment added yet. Add treadmills, dumbbells and more with counts.
</p>
) : (
<div className="flex flex-col gap-2">
{equipment.map((row, index) => {
const options = EQUIPMENT_OPTIONS.includes(row.name) || !row.name
? EQUIPMENT_OPTIONS
: [row.name, ...EQUIPMENT_OPTIONS]
const items = options.map((o) => ({ label: o, value: o }))
return (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Select
items={items}
value={row.name || null}
onValueChange={(v) => updateEquipment(index, { name: v ?? "" })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select equipment" />
</SelectTrigger>
<SelectContent>
{items.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
type="number"
min={0}
aria-label="Count"
className="w-24"
value={row.count}
onChange={(e) =>
updateEquipment(index, { count: Number(e.target.value) })
}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remove equipment"
onClick={() => removeEquipment(index)}
>
<XIcon />
</Button>
</div>
)
})}
</div>
)}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { getUnlinkedFingerprints, linkFingerprint } from "@/lib/api/customers"
import type { MemberSubscription, UnlinkedFingerprint } from "@/lib/models/customer"
import { subscribeAccessEvents } from "@/lib/realtime/access-events"
import { runMutation } from "@/lib/use-mutation"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
function LinkFingerprintDialogForm({
member,
onOpenChange,
onLinked,
}: {
member: MemberSubscription
onOpenChange: (open: boolean) => void
onLinked: () => void
}) {
const [options, setOptions] = React.useState<UnlinkedFingerprint[]>([])
const [loading, setLoading] = React.useState(true)
const [selectedId, setSelectedId] = React.useState("")
const [saving, setSaving] = React.useState(false)
const loadOptions = React.useCallback(async (background = false) => {
if (!background) setLoading(true)
try {
setOptions(await getUnlinkedFingerprints())
} catch {
if (!background) toast.error("Couldn't load unlinked fingerprints.")
} finally {
if (!background) setLoading(false)
}
}, [])
React.useEffect(() => {
void loadOptions()
const unsubscribe = subscribeAccessEvents((event) => {
if (event.type === "deviceInventory") void loadOptions(true)
})
// Fallback for proxies or browsers where WebSockets are unavailable.
const timer = window.setInterval(() => void loadOptions(true), 10_000)
return () => {
unsubscribe()
window.clearInterval(timer)
}
}, [loadOptions])
async function handleLink() {
if (!selectedId) return
setSaving(true)
try {
await runMutation(() => linkFingerprint(member.fkCustomerId, selectedId))
toast.success("Device person linked to member.")
onLinked()
onOpenChange(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setSaving(false)
}
}
return (
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Link existing device person</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
Pick the person record for {member.customer?.cusName ?? "this member"} that was enrolled directly on a terminal.
</p>
<Field>
<Label>Terminal person</Label>
<Select value={selectedId} onValueChange={(v) => setSelectedId(v ?? "")}>
<SelectTrigger disabled={loading}>
<SelectValue placeholder={loading ? "Loading…" : "Select a device user"} />
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No unlinked device users found.</div>
) : (
options.map((o) => (
<SelectItem key={o.id} value={o.id}>
#{o.deviceEmployeeNo || o.deviceUserId} {o.deviceReportedName ? `— ${o.deviceReportedName}` : ""}
{` · ${o.deviceName ?? "Terminal"} · ${o.fingerprintCount} finger(s)`}
</SelectItem>
))
)}
</SelectContent>
</Select>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => void handleLink()} disabled={!selectedId || saving}>
{saving && <Spinner className="size-4" />}
Link
</Button>
</DialogFooter>
</DialogContent>
)
}
function LinkFingerprintDialog({
member,
onOpenChange,
onLinked,
}: {
member: MemberSubscription | null
onOpenChange: (open: boolean) => void
onLinked: () => void
}) {
return (
<Dialog open={!!member} onOpenChange={onOpenChange} disablePointerDismissal>
{member ? (
<LinkFingerprintDialogForm
key={member.fkCustomerId}
member={member}
onOpenChange={onOpenChange}
onLinked={onLinked}
/>
) : null}
</Dialog>
)
}
export { LinkFingerprintDialog }
@@ -0,0 +1,126 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { getLockPolicy, updateLockPolicy } from "@/lib/api/lock-policy"
import { runMutation } from "@/lib/use-mutation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Field } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
const PRESETS = [1, 2, 3, 5, 7]
interface LockPolicyViewProps {
/** Sandbox mode keeps the policy in local component state. */
demo?: boolean
}
function LockPolicyView({ demo = false }: LockPolicyViewProps) {
const [days, setDays] = React.useState<string>(demo ? "3" : "")
const [loading, setLoading] = React.useState(!demo)
const [saving, setSaving] = React.useState(false)
React.useEffect(() => {
if (demo) return
void (async () => {
try {
const policy = await getLockPolicy()
setDays(policy.lockAfterDaysOverdue != null ? String(policy.lockAfterDaysOverdue) : "")
} catch {
toast.error("Couldn't load the auto-lock setting.")
} finally {
setLoading(false)
}
})()
}, [demo])
async function handleSave(value: string) {
setSaving(true)
try {
const parsed = value.trim() ? Number(value) : null
if (demo) {
setDays(parsed != null ? String(parsed) : "")
toast.success(
parsed
? `Demo members will be auto-locked after ${parsed} day(s) overdue.`
: "Demo auto-lock disabled."
)
return
}
const saved = await runMutation(() => updateLockPolicy(parsed))
setDays(saved.lockAfterDaysOverdue != null ? String(saved.lockAfterDaysOverdue) : "")
toast.success(
saved.lockAfterDaysOverdue
? `Members will be auto-locked after ${saved.lockAfterDaysOverdue} day(s) overdue.`
: "Auto-lock disabled."
)
} catch {
// runMutation() already toasted the real error.
} finally {
setSaving(false)
}
}
if (loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<Card>
<CardHeader>
<CardTitle>Auto-lock after non-payment</CardTitle>
<CardDescription>
If a member&apos;s payment is overdue by this many days, their fingerprint access is automatically locked.
You can always lock or unlock a member manually from their profile regardless of this setting.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => (
<Button
key={p}
type="button"
size="sm"
variant={days === String(p) ? "default" : "outline"}
onClick={() => void handleSave(String(p))}
disabled={saving}
>
{p} day{p > 1 ? "s" : ""}
</Button>
))}
<Button
type="button"
size="sm"
variant={!days ? "default" : "outline"}
onClick={() => void handleSave("")}
disabled={saving}
>
Disabled
</Button>
</div>
<Field className="max-w-56">
<Label>Custom (days)</Label>
<div className="flex gap-2">
<Input
type="number"
min={0}
value={days}
onChange={(e) => setDays(e.target.value)}
placeholder="Disabled"
/>
<Button type="button" onClick={() => void handleSave(days)} disabled={saving}>
Save
</Button>
</div>
</Field>
</CardContent>
</Card>
)
}
export { LockPolicyView }
@@ -0,0 +1,58 @@
"use client"
import {
assignMealPlan,
createMealCategory,
createMealItem,
createMealPlan,
deleteMealPlan,
getMealCategories,
getMealItems,
getMealPlans,
unassignMealPlan,
updateMealPlan,
} from "@/lib/api/meal-plans"
import type { CreatePlanInput } from "@/lib/models/plan"
import { PlanManagementView } from "@/components/hexa/views/plan-management-view"
import type { PlanItemDraft } from "@/components/hexa/views/plan-editor-dialog"
/** Editor rows → mealItems payload. Each row is one meal with a name + food list. */
function mapMealItems(items: PlanItemDraft[]) {
return items.map((it) => ({
name: it.label,
description: it.detail,
mealItems: it.detail
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean),
}))
}
interface MealPlansViewProps {
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function MealPlansView({ demo = false }: MealPlansViewProps) {
return (
<PlanManagementView
kind="meal"
title="Meal Plans"
description="Build and assign structured meal plans"
getPlans={getMealPlans}
getCategories={getMealCategories}
createCategory={createMealCategory}
createPlan={(input) => createMealPlan(input as CreatePlanInput & { mealItems?: unknown[] })}
updatePlan={(id, input) => updateMealPlan(id, input)}
deletePlan={deleteMealPlan}
assignPlan={assignMealPlan}
unassignPlan={unassignMealPlan}
getItems={getMealItems}
createItem={createMealItem}
mapItems={mapMealItems}
demo={demo}
/>
)
}
export { MealPlansView }
@@ -0,0 +1,659 @@
"use client"
import * as React from "react"
import { CameraIcon, RefreshCwIcon, UploadIcon } from "lucide-react"
import type { GymPlan } from "@/lib/models/gym-plan"
import type { MemberInput } from "@/lib/models/customer"
import type { GroupPlanOption } from "@/components/hexa/views/assign-plan-dialog"
import {
FacePhotoError,
normalizeFacePhoto,
type NormalizedFacePhoto,
} from "@/lib/face-photo"
import { ageFromBirthday, clampDiscountValue } from "@/lib/validation"
import { useMutation } from "@/lib/use-mutation"
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const GENDER_OPTIONS = [
{ label: "Male", value: "Male" },
{ label: "Female", value: "Female" },
{ label: "Other", value: "Other" },
]
const DISCOUNT_TYPE_ITEMS = [
{ label: "Percentage", value: "Percentage" },
{ label: "Flat amount", value: "Flat" },
]
interface MemberFormValues {
fullname: string
userName: string
email: string
mobileNumber: string
gender: string
birthday: string
nic: string
/** Create mode only: "new" starts a fresh plan (customerPlanId below); "join" attaches
* this member to an existing group subscription (groupSubscriptionId below) instead. */
membershipMode: "new" | "join"
customerPlanId: string
groupSubscriptionId: string
/** "now" sends the current date/time; "manual" sends the date picked below. */
onboardDateMode: "now" | "manual"
onboardDate: string
chargePayment: boolean
giveDiscount: boolean
discountType: "Percentage" | "Flat"
discountValue: string
}
const EMPTY_VALUES: MemberFormValues = {
fullname: "",
userName: "",
email: "",
mobileNumber: "",
gender: "",
birthday: "",
nic: "",
membershipMode: "new",
customerPlanId: "",
groupSubscriptionId: "",
onboardDateMode: "now",
onboardDate: "",
chargePayment: true,
giveDiscount: false,
discountType: "Percentage",
discountValue: "",
}
interface MemberFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/**
* Receives the form values, an optional face photo, and — when the "Join existing group"
* tab was used — the subscription to attach the new member to. The photo cannot be sent
* with the member (the endpoint is keyed on a customer id that only exists after
* creation), so the caller uploads it, and joins the group, once the member exists.
*/
onSubmit: (values: MemberInput, facePhotoBase64?: string, joinGroupSubscriptionId?: string) => Promise<void>
/** Active gym plans for the membership select (create mode only). */
plans: GymPlan[]
/** Existing Group-scope subscriptions with room, for the "Join existing group" tab (create mode only). */
groupOptions: GroupPlanOption[]
mode: "create" | "edit"
initialValues?: Partial<MemberFormValues>
}
function MemberFormDialog({
open,
onOpenChange,
onSubmit,
plans,
groupOptions,
mode,
initialValues,
}: MemberFormDialogProps) {
const [values, setValues] = React.useState<MemberFormValues>(EMPTY_VALUES)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
// Optional face photo for the access terminal. Held in memory only until the member is
// created, since the upload endpoint needs the new customer's id.
const [photo, setPhoto] = React.useState<NormalizedFacePhoto | null>(null)
const [photoBusy, setPhotoBusy] = React.useState(false)
const [photoError, setPhotoError] = React.useState<string | null>(null)
const [cameraOn, setCameraOn] = React.useState(false)
const videoRef = React.useRef<HTMLVideoElement>(null)
const streamRef = React.useRef<MediaStream | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const stopCamera = React.useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
setCameraOn(false)
}, [])
// Release the camera whenever the dialog is closed, and on unmount. Refs cannot be
// touched during render, so this can't live in the open/prevOpen block below.
React.useEffect(() => {
if (!open) {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
return () => {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
}, [open])
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setValues({ ...EMPTY_VALUES, ...initialValues })
setPhoto(null)
setPhotoError(null)
} else {
setCameraOn(false)
}
}
async function startCamera() {
setPhotoError(null)
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
})
streamRef.current = stream
setCameraOn(true)
requestAnimationFrame(() => {
if (videoRef.current) {
videoRef.current.srcObject = stream
void videoRef.current.play()
}
})
} catch (err) {
const name = err instanceof Error ? err.name : ""
setPhotoError(
name === "NotAllowedError"
? "Camera access was blocked. Allow the camera, or upload a photo instead."
: name === "NotFoundError"
? "No camera was found. Upload a photo instead."
: "Could not start the camera. Upload a photo instead."
)
}
}
async function capturePhoto() {
if (!videoRef.current) return
setPhotoBusy(true)
setPhotoError(null)
try {
const normalized = await normalizeFacePhoto(videoRef.current)
stopCamera()
setPhoto(normalized)
} catch (err) {
setPhotoError(
err instanceof FacePhotoError ? err.message : "Could not capture that frame."
)
} finally {
setPhotoBusy(false)
}
}
async function handlePhotoFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file) return
setPhotoBusy(true)
setPhotoError(null)
try {
const normalized = await normalizeFacePhoto(file)
stopCamera()
setPhoto(normalized)
} catch (err) {
setPhotoError(
err instanceof FacePhotoError ? err.message : "Could not read that image file."
)
} finally {
setPhotoBusy(false)
}
}
const isNewPlan = mode === "create" && values.membershipMode === "new" && !!values.customerPlanId
const isJoiningGroup = mode === "create" && values.membershipMode === "join" && !!values.groupSubscriptionId
const needsManualOnboardDate = isNewPlan && values.onboardDateMode === "manual"
const selectedPlanPrice = plans.find((p) => p.customerPlanId === values.customerPlanId)?.planPrice
const canSubmit =
values.fullname.trim() &&
values.userName.trim() &&
values.email.trim() &&
values.mobileNumber.trim() &&
values.gender &&
(!needsManualOnboardDate || values.onboardDate) &&
!busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() =>
onSubmit({
fullname: values.fullname.trim(),
userName: values.userName.trim(),
email: values.email.trim(),
mobileNumber: values.mobileNumber.trim(),
gender: values.gender,
birthday: values.birthday,
nic: values.nic.trim() || undefined,
customerPlanId: isNewPlan ? values.customerPlanId : undefined,
onboardDate: isNewPlan
? (values.onboardDateMode === "manual" && values.onboardDate
? new Date(values.onboardDate).toISOString()
: new Date().toISOString())
: undefined,
chargePayment: isNewPlan ? values.chargePayment : undefined,
discountType: isNewPlan && values.giveDiscount && values.discountValue ? values.discountType : undefined,
discountValue: isNewPlan && values.giveDiscount && values.discountValue ? Number(values.discountValue) : undefined,
},
mode === "create" ? photo?.base64 : undefined,
isJoiningGroup ? values.groupSubscriptionId : undefined)
)
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open so the user can retry.
}
}
const planItems = plans.map((p) => ({
label: `${p.planName} — Rs. ${p.planPrice.toLocaleString()}`,
value: p.customerPlanId,
}))
const age = values.birthday ? ageFromBirthday(values.birthday) : null
const underAge = age !== null && age < 10
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Add Member" : "Edit Member"}</DialogTitle>
<DialogDescription>
{mode === "create"
? "Register a new member for your gym."
: "Update this member's profile."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="member-fullname">
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id="member-fullname"
placeholder="e.g. Nimal Perera"
value={values.fullname}
onChange={(e) => setValues((v) => ({ ...v, fullname: e.target.value }))}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="member-username">
Username <span className="text-destructive">*</span>
</Label>
<Input
id="member-username"
placeholder="Login username"
value={values.userName}
onChange={(e) => setValues((v) => ({ ...v, userName: e.target.value }))}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="member-mobile">
Mobile <span className="text-destructive">*</span>
</Label>
<Input
id="member-mobile"
type="tel"
placeholder="07XXXXXXXX"
value={values.mobileNumber}
onChange={(e) => setValues((v) => ({ ...v, mobileNumber: e.target.value }))}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="member-email">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="member-email"
type="email"
placeholder="member@email.com"
value={values.email}
onChange={(e) => setValues((v) => ({ ...v, email: e.target.value }))}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>
Gender <span className="text-destructive">*</span>
</Label>
<Select
items={GENDER_OPTIONS}
value={values.gender || null}
onValueChange={(v) => setValues((val) => ({ ...val, gender: v ?? "" }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select gender" />
</SelectTrigger>
<SelectContent>
{GENDER_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="member-birthday">Birthday</Label>
<Input
id="member-birthday"
type="date"
value={values.birthday}
onChange={(e) => setValues((v) => ({ ...v, birthday: e.target.value }))}
// The picker opens on today, so without a max a stray click saves
// today's date -- an age of 0, which breaks progress predictions.
max={new Date().toISOString().slice(0, 10)}
/>
</div>
</div>
{underAge && (
<p className="-mt-2 text-xs text-amber-600 dark:text-amber-500">
This member is under 10 years old ({age} yrs). You can still add them, but please double-check the birthday.
</p>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="member-nic">NIC</Label>
<Input
id="member-nic"
placeholder="National ID number"
value={values.nic}
onChange={(e) => setValues((v) => ({ ...v, nic: e.target.value }))}
/>
</div>
{mode === "create" && (
<div className="flex flex-col gap-1.5">
<Label>
Face Photo{" "}
<span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<div className="flex items-start gap-3">
<div className="relative size-24 shrink-0 overflow-hidden rounded-lg border bg-muted">
{cameraOn ? (
<video ref={videoRef} playsInline muted className="size-full object-cover" />
) : photo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photo.previewUrl} alt="Face photo preview" className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center">
<CameraIcon className="size-6 text-muted-foreground" />
</div>
)}
{photoBusy && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70">
<Spinner className="size-5" />
</div>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{cameraOn ? (
<>
<Button type="button" size="sm" onClick={capturePhoto} disabled={photoBusy}>
<CameraIcon />
Capture
</Button>
<Button type="button" variant="outline" size="sm" onClick={stopCamera} disabled={photoBusy}>
Cancel
</Button>
</>
) : (
<>
<Button type="button" variant="outline" size="sm" onClick={startCamera} disabled={photoBusy}>
<CameraIcon />
{photo ? "Retake" : "Camera"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={photoBusy}
>
{photo ? <RefreshCwIcon /> : <UploadIcon />}
{photo ? "Replace" : "Upload"}
</Button>
</>
)}
</div>
<p className="text-xs text-muted-foreground">
{photo
? `Ready — ${Math.round(photo.bytes / 1024)} KB. Sent to the access terminal after the member is created.`
: "For fingerprint/face access. You can also add this later from the member list."}
</p>
</div>
</div>
{photoError && (
<p className="text-xs text-destructive">{photoError}</p>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoFile}
/>
</div>
)}
{mode === "create" && (
<div className="flex flex-col gap-1.5">
<Label>Membership</Label>
<Tabs
value={values.membershipMode}
onValueChange={(v) =>
setValues((val) => ({ ...val, membershipMode: (v as "new" | "join") ?? "new" }))
}
>
<TabsList className="w-full">
<TabsTrigger value="new" className="flex-1">New plan</TabsTrigger>
<TabsTrigger value="join" className="flex-1">Join existing group</TabsTrigger>
</TabsList>
<TabsContent value="new" className="mt-2 flex flex-col gap-1.5">
<Select
items={planItems}
value={values.customerPlanId || null}
onValueChange={(v) => setValues((val) => ({ ...val, customerPlanId: v ?? "" }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="No plan yet" />
</SelectTrigger>
<SelectContent>
{planItems.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground mb-4">
{values.customerPlanId
? "The customer will receive a payment link for the plan price plus any one-time onboarding fee."
: "You can leave this member without a plan and assign one later."}
</span>
</TabsContent>
<TabsContent value="join" className="mt-2 flex flex-col gap-1.5">
<Select
items={groupOptions.map((g) => ({ label: g.label, value: g.subscriptionId }))}
value={values.groupSubscriptionId || null}
onValueChange={(v) => setValues((val) => ({ ...val, groupSubscriptionId: v ?? "" }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={groupOptions.length ? "Choose a group plan" : "No group plans with room"} />
</SelectTrigger>
<SelectContent>
{groupOptions.map((opt) => (
<SelectItem key={opt.subscriptionId} value={opt.subscriptionId}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground mb-4">
Adds this member to an existing couple/family plan — no separate charge.
</span>
</TabsContent>
</Tabs>
</div>
)}
{isNewPlan && (
<>
<div className="flex flex-col gap-1.5">
<Label>Onboard Date</Label>
<RadioGroup
value={values.onboardDateMode}
onValueChange={(v) =>
setValues((val) => ({ ...val, onboardDateMode: (v as "now" | "manual") ?? "now" }))
}
className="grid-cols-2"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="now" /> Today (default)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="manual" /> Pick a date
</label>
</RadioGroup>
{values.onboardDateMode === "manual" && (
<Input
type="date"
value={values.onboardDate}
onChange={(e) => setValues((v) => ({ ...v, onboardDate: e.target.value }))}
required={needsManualOnboardDate}
/>
)}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="leading-tight">
<Label htmlFor="member-charge-payment">Charge Payment</Label>
<p className="text-xs text-muted-foreground">
{values.chargePayment
? "The invoice will bill the plan price as usual."
: "The invoice will be created for Rs. 0.00 and marked Paid automatically — no payment link will be sent."}
</p>
</div>
<Switch
id="member-charge-payment"
checked={values.chargePayment}
onCheckedChange={(checked) => setValues((v) => ({ ...v, chargePayment: checked }))}
/>
</div>
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="leading-tight">
<Label htmlFor="member-give-discount">Give Discount</Label>
<p className="text-xs text-muted-foreground">
Optionally reduce the plan price for this member.
</p>
</div>
<Switch
id="member-give-discount"
checked={values.giveDiscount}
onCheckedChange={(checked) => setValues((v) => ({ ...v, giveDiscount: checked }))}
/>
</div>
{values.giveDiscount && (
<div className="flex gap-2">
<Select
items={DISCOUNT_TYPE_ITEMS}
value={values.discountType}
onValueChange={(v) =>
setValues((val) => {
const discountType = (v as "Percentage" | "Flat") ?? "Percentage"
return {
...val,
discountType,
discountValue: clampDiscountValue(val.discountValue, discountType, selectedPlanPrice),
}
})
}
>
<SelectTrigger className="w-36 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISCOUNT_TYPE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={0}
max={values.discountType === "Percentage" ? 100 : selectedPlanPrice}
placeholder={values.discountType === "Percentage" ? "e.g. 10 (%)" : "e.g. 500 (Rs.)"}
value={values.discountValue}
onChange={(e) =>
setValues((v) => ({
...v,
discountValue: clampDiscountValue(e.target.value, v.discountType, selectedPlanPrice),
}))
}
/>
</div>
)}
</div>
</>
)}
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Add Member" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { MemberFormDialog }
export type { MemberFormValues }
@@ -0,0 +1,313 @@
"use client"
import * as React from "react"
import { HeartPulseIcon, LockIcon } from "lucide-react"
import { toast } from "sonner"
import { getMemberFull, unlockMedical } from "@/lib/api/customers"
import { formatCurrency, formatDate } from "@/lib/format"
import { useMediaQuery } from "@/lib/use-media-query"
import { runMutation } from "@/lib/use-mutation"
import { DEMO_MEMBER_EXTRAS, DEMO_MEMBER_PROFILES, DEMO_SUBSCRIPTIONS } from "@/lib/demo/mock-data"
import type { FollowedPlan, MedicalDetails, MemberFullDetail } from "@/lib/models/customer"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { hasWeightProgress, MemberWeightChart } from "@/components/hexa/views/member-weight-chart"
interface MemberFullProfileViewProps {
customerId: string | null
open: boolean
onOpenChange: (open: boolean) => void
/** Sandbox demo mode: build the detail from local mock data instead of calling the backend. */
demo?: boolean
}
function PlanList({ title, plans }: { title: string; plans: FollowedPlan[] }) {
return (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">{title}</p>
{plans.length === 0 ? (
<p className="text-sm text-muted-foreground">None assigned.</p>
) : (
<ul className="flex flex-col gap-1">
{plans.map((p) => (
<li key={p.planId} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span>{p.name ?? "Unnamed plan"}</span>
{p.completedDate ? (
<Badge variant="outline">Completed</Badge>
) : p.isFollowing ? (
<Badge>Following</Badge>
) : (
<Badge variant="outline">Assigned</Badge>
)}
</li>
))}
</ul>
)}
</div>
)
}
export function MemberFullProfileView({ customerId, open, onOpenChange, demo = false }: MemberFullProfileViewProps) {
const [detail, setDetail] = React.useState<MemberFullDetail | null>(null)
const [loading, setLoading] = React.useState(false)
const [medical, setMedical] = React.useState<MedicalDetails | null>(null)
const [password, setPassword] = React.useState("")
const [unlocking, setUnlocking] = React.useState(false)
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (!open) {
// Clear sensitive state when the dialog closes.
setMedical(null)
setPassword("")
setDetail(null)
}
}
const demoDetail = React.useMemo<MemberFullDetail | null>(() => {
if (!demo || !open || !customerId) return null
const profile = DEMO_MEMBER_PROFILES.find((p) => p.cusId === customerId)
if (!profile) return null
const extras = DEMO_MEMBER_EXTRAS[customerId] ?? { workoutPlans: [], mealPlans: [], medical: null }
return {
customer: profile,
subscriptions: DEMO_SUBSCRIPTIONS.filter((s) => s.members.some((m) => m.customerId === customerId)),
workoutPlans: extras.workoutPlans,
mealPlans: extras.mealPlans,
}
}, [demo, open, customerId])
React.useEffect(() => {
if (!open || !customerId || demo) return
let cancelled = false
async function load(id: string) {
setLoading(true)
try {
const d = await getMemberFull(id)
if (!cancelled) setDetail(d)
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : "Couldn't load the full profile.")
onOpenChange(false)
}
} finally {
if (!cancelled) setLoading(false)
}
}
load(customerId)
return () => {
cancelled = true
}
}, [open, customerId, onOpenChange, demo])
async function handleUnlock(e: React.FormEvent) {
e.preventDefault()
if (!customerId || !password) return
if (demo) {
const extras = DEMO_MEMBER_EXTRAS[customerId] ?? { workoutPlans: [], mealPlans: [], medical: null }
setMedical(extras.medical ?? { bloodGroup: null, disabilities: null })
setPassword("")
toast.success("Medical details unlocked")
return
}
setUnlocking(true)
try {
const result = await runMutation(() => unlockMedical(customerId, password))
setMedical(result)
setPassword("")
toast.success("Medical details unlocked")
} catch {
// runMutation() already toasted the real error.
} finally {
setUnlocking(false)
}
}
const isDesktop = useMediaQuery("(min-width: 768px)")
const effectiveDetail = demo ? demoDetail : detail
const effectiveLoading = demo ? false : loading
const customer = effectiveDetail?.customer
const target = customer?.customerTargets?.[0]
const hasChart = hasWeightProgress(customer?.customerTargets)
const hasProgressData =
!!target?.currentHeight || !!target?.currentWeight || !!target?.targetWeight || !!target?.goal || hasChart
const body = (
<>
{effectiveLoading ? (
<div className="flex flex-col gap-3 py-2">
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
<div className="h-32 animate-pulse rounded-lg bg-muted" />
</div>
) : effectiveDetail && customer ? (
<div className="flex flex-col gap-5 py-1">
<section className="flex flex-col gap-2">
<p className="text-sm font-medium">Basic details</p>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Phone</dt>
<dd>{customer.primaryPH ?? "—"}</dd>
<dt className="text-muted-foreground">Email</dt>
<dd className="truncate">{customer.email ?? "—"}</dd>
<dt className="text-muted-foreground">Gender</dt>
<dd>{customer.gender ?? "—"}</dd>
<dt className="text-muted-foreground">Birthday</dt>
<dd>{customer.boD ?? "—"}</dd>
<dt className="text-muted-foreground">NIC</dt>
<dd>{customer.nic ?? "—"}</dd>
<dt className="text-muted-foreground">Joined</dt>
<dd>{formatDate(customer.createdAt)}</dd>
</dl>
</section>
{hasProgressData && (
<section className="flex flex-col gap-2">
<p className="text-sm font-medium">Progress</p>
{(target?.currentHeight || target?.currentWeight || target?.targetWeight || target?.goal) && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{target?.currentHeight && <Stat label="Height" value={`${target.currentHeight} cm`} />}
{target?.currentWeight && <Stat label="Weight" value={`${target.currentWeight} kg`} />}
{target?.targetWeight && <Stat label="Target" value={`${target.targetWeight} kg`} />}
{target?.goal && <Stat label="Goal" value={target.goal} />}
</div>
)}
{hasChart && <MemberWeightChart targets={customer.customerTargets} />}
</section>
)}
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<PlanList title="Workout plans" plans={effectiveDetail.workoutPlans} />
<PlanList title="Meal plans" plans={effectiveDetail.mealPlans} />
</section>
<section className="flex flex-col gap-2">
<p className="text-sm font-medium">Subscriptions & payments</p>
{!effectiveDetail.subscriptions || effectiveDetail.subscriptions.length === 0 ? (
<p className="text-sm text-muted-foreground">No subscriptions.</p>
) : (
<ul className="flex flex-col gap-2">
{effectiveDetail.subscriptions.map((s) => (
<li key={s.subscriptionId} className="rounded-md border px-3 py-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">{s.planName ?? "Plan"}</span>
<Badge variant="outline">{s.paymentStatus}</Badge>
</div>
<p className="text-xs text-muted-foreground">
Paid {formatCurrency(s.paidAmount)} of {formatCurrency(s.invoiceAmount)} ·{" "}
{s.balance > 0 ? `${formatCurrency(s.balance)} due` : "Settled"}
</p>
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<HeartPulseIcon className="size-4 text-destructive" />
<p className="text-sm font-medium">Medical details</p>
</div>
{medical ? (
medical.bloodGroup || medical.disabilities ? (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{medical.bloodGroup && (
<>
<dt className="text-muted-foreground">Blood group</dt>
<dd>{medical.bloodGroup}</dd>
</>
)}
{medical.disabilities && (
<>
<dt className="text-muted-foreground">Disabilities / notes</dt>
<dd>{medical.disabilities}</dd>
</>
)}
</dl>
) : (
<p className="text-sm text-muted-foreground">No medical details recorded for this member.</p>
)
) : (
<form onSubmit={handleUnlock} className="flex flex-col gap-2">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<LockIcon className="size-3.5" />
Enter your account password to view medical details.
</p>
<div className="flex gap-2">
<Input
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<Button type="submit" disabled={unlocking || !password}>
{unlocking && <Spinner className="size-4" />}
Unlock
</Button>
</div>
</form>
)}
</section>
</div>
) : null}
</>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[92vh] max-w-2xl flex-col overflow-y-auto">
<DialogHeader>
<DialogTitle>{customer?.cusName ?? "Member profile"}</DialogTitle>
<DialogDescription>Full member profile, progress, plans and medical details</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>{customer?.cusName ?? "Member profile"}</SheetTitle>
<SheetDescription>Full member profile, progress, plans and medical details</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="rounded-lg border p-2.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium">{value}</p>
</div>
)
}
@@ -0,0 +1,408 @@
"use client"
import * as React from "react"
import { CrownIcon, PlusIcon, SendIcon, WalletIcon, XIcon, ZapIcon } from "lucide-react"
import { toast } from "sonner"
import { attemptAutoCharge, sendPaymentLink } from "@/lib/api/invoices"
import { addSubscriptionExtraFee, getSubscriptionExtraFees, getSubscriptionPayments } from "@/lib/api/subscriptions"
import { formatCurrency, formatDate, formatDateTime } from "@/lib/format"
import { runMutation } from "@/lib/use-mutation"
import { DEMO_SUBSCRIPTION_PAYMENTS } from "@/lib/demo/mock-data"
import type {
AddExtraFeeInput,
ExtraFeeFrequency,
PaymentStatus,
SubscriptionExtraFee,
SubscriptionPayment,
SubscriptionSummary,
} from "@/lib/models/subscription"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { StatusBadge } from "@/components/hexa/status-badge"
function statusTone(status: PaymentStatus) {
return status === "Paid" ? "success" : status === "Partial" ? "warning" : "danger"
}
function isOverdue(dueDate: string, status: PaymentStatus) {
return status !== "Paid" && new Date(dueDate) < new Date()
}
interface MemberSubscriptionCardProps {
summary: SubscriptionSummary
/** Show member-management controls (add/remove); off for the read-only member's own view. */
manageable?: boolean
onRecordPayment: (summary: SubscriptionSummary) => void
onAddMembers: (summary: SubscriptionSummary) => void
onRemoveMember: (summary: SubscriptionSummary, subscriptionMemberId: string) => void
/** Called after a successful auto-charge attempt or payment-link send, so the parent can refresh. */
onChanged?: () => void
/** Sandbox demo mode: mutate local state instead of calling the backend. */
demo?: boolean
/** This subscription's demo extra fees, owned by the parent so the balance stays in sync. */
demoExtraFees?: SubscriptionExtraFee[]
demoAddExtraFee?: (subscriptionId: string, input: AddExtraFeeInput) => void
}
function MemberSubscriptionCard({
summary,
manageable = true,
onRecordPayment,
onAddMembers,
onRemoveMember,
onChanged,
demo = false,
demoExtraFees,
demoAddExtraFee,
}: MemberSubscriptionCardProps) {
const [payments, setPayments] = React.useState<SubscriptionPayment[] | null>(null)
const [loadingPayments, setLoadingPayments] = React.useState(false)
const [chargingBusy, setChargingBusy] = React.useState(false)
const [linkBusy, setLinkBusy] = React.useState(false)
const [extraFees, setExtraFees] = React.useState<SubscriptionExtraFee[] | null>(null)
const [loadingFees, setLoadingFees] = React.useState(false)
const [addingFee, setAddingFee] = React.useState(false)
const [feeName, setFeeName] = React.useState("")
const [feeAmount, setFeeAmount] = React.useState("")
const [feeFrequency, setFeeFrequency] = React.useState<ExtraFeeFrequency>("OneTime")
const [savingFee, setSavingFee] = React.useState(false)
const paidPct = summary.invoiceAmount > 0
? Math.min(100, Math.round((summary.paidAmount / summary.invoiceAmount) * 100))
: 0
const nextUnpaid = summary.installments.find((i) => i.status !== "Paid")
async function loadPayments() {
if (payments || loadingPayments) return
if (demo) {
setPayments(DEMO_SUBSCRIPTION_PAYMENTS[summary.subscriptionId] ?? [])
return
}
setLoadingPayments(true)
try {
const result = await getSubscriptionPayments(summary.subscriptionId)
setPayments(result.payments)
} catch {
toast.error("Couldn't load payment history.")
} finally {
setLoadingPayments(false)
}
}
async function loadExtraFees() {
if (extraFees || loadingFees) return
if (demo) {
setExtraFees(demoExtraFees ?? [])
return
}
setLoadingFees(true)
try {
setExtraFees(await getSubscriptionExtraFees(summary.subscriptionId))
} catch {
toast.error("Couldn't load extra fees.")
} finally {
setLoadingFees(false)
}
}
async function handleAddExtraFee() {
const amount = Number(feeAmount) || 0
if (!feeName.trim() || amount <= 0) return
setSavingFee(true)
try {
const input = { name: feeName.trim(), amount, frequency: feeFrequency }
if (demo) {
demoAddExtraFee?.(summary.subscriptionId, input)
toast.success("Extra fee added and invoiced.")
setFeeName("")
setFeeAmount("")
setFeeFrequency("OneTime")
setAddingFee(false)
onChanged?.()
return
}
await runMutation(() => addSubscriptionExtraFee(summary.subscriptionId, input))
toast.success("Extra fee added and invoiced.")
setFeeName("")
setFeeAmount("")
setFeeFrequency("OneTime")
setAddingFee(false)
setExtraFees(await getSubscriptionExtraFees(summary.subscriptionId))
onChanged?.()
} catch {
// runMutation() already toasted the real error.
} finally {
setSavingFee(false)
}
}
const isGroup = summary.planScope === "Group"
const atLimit = summary.members.length >= summary.memberLimit
async function handleChargeCard() {
const invoiceId = summary.customerInvoiceId
if (!invoiceId || chargingBusy) return
setChargingBusy(true)
try {
if (demo) {
toast.success("Charge attempt recorded — pending confirmation.")
return
}
const result = await runMutation(() => attemptAutoCharge(invoiceId))
toast.success(result.message || "Charge attempt recorded — pending confirmation.")
onChanged?.()
} catch {
// runMutation() already toasted the real error.
} finally {
setChargingBusy(false)
}
}
async function handleSendLink() {
const invoiceId = summary.customerInvoiceId
if (!invoiceId || linkBusy) return
setLinkBusy(true)
try {
if (demo) {
toast.success("Payment link sent to the member.")
return
}
await runMutation(() => sendPaymentLink(invoiceId))
toast.success("Payment link sent to the member.")
} catch {
// runMutation() already toasted the real error.
} finally {
setLinkBusy(false)
}
}
const displayedExtraFees = demo ? (demoExtraFees ?? []) : extraFees
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-3">
{/* Header: plan + status */}
<div className="flex items-start justify-between gap-2">
<div className="leading-tight">
<p className="font-medium">{summary.planName ?? "Plan"}</p>
<div className="mt-1 flex flex-wrap gap-1.5">
<Badge variant={isGroup ? "default" : "outline"}>
{isGroup ? `Group of ${summary.memberLimit}` : "Individual"}
</Badge>
{summary.allowSplitPayments && <Badge variant="outline">Split pay</Badge>}
</div>
</div>
<StatusBadge status={statusTone(summary.paymentStatus)}>{summary.paymentStatus}</StatusBadge>
</div>
{/* Dates */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Joined {formatDate(summary.onboardDate)}</span>
<span>{summary.renewalDate ? `Expires ${formatDate(summary.renewalDate)}` : "No expiry"}</span>
</div>
{/* Money */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{formatCurrency(summary.paidAmount)} / {formatCurrency(summary.invoiceAmount)}
</span>
<span className="font-medium">{formatCurrency(summary.balance)} due</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${paidPct}%` }} />
</div>
</div>
{/* Installment schedule */}
{summary.installments.length > 1 && (
<div className="flex flex-col gap-1">
<p className="text-xs font-medium text-muted-foreground">Installments</p>
<div className="flex flex-col divide-y rounded-lg border">
{summary.installments.map((inst) => (
<div
key={inst.subscriptionInstallmentId}
className={cn(
"flex items-center justify-between px-2.5 py-1.5 text-sm",
isOverdue(inst.dueDate, inst.status) && "bg-destructive/5"
)}
>
<span className="text-muted-foreground">
#{inst.installmentNo} · {formatDate(inst.dueDate)}
{isOverdue(inst.dueDate, inst.status) && (
<span className="ml-1.5 text-xs font-medium text-destructive">overdue</span>
)}
</span>
<span className="flex items-center gap-2">
<span>{formatCurrency(inst.amount)}</span>
<StatusBadge status={statusTone(inst.status)} dot={false} className="h-5 px-2 text-[0.65rem]">
{inst.status}
</StatusBadge>
</span>
</div>
))}
</div>
</div>
)}
{/* Members */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
Members ({summary.members.length}/{summary.memberLimit})
</p>
{manageable && isGroup && !atLimit && (
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onAddMembers(summary)}>
<PlusIcon className="size-3.5" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-1">
{summary.members.map((m) => (
<div key={m.subscriptionMemberId} className="flex items-center justify-between rounded-lg border px-2.5 py-1.5 text-sm">
<span className="flex items-center gap-1.5">
{m.isPrimary && <CrownIcon className="size-3.5 text-amber-500" />}
{m.cusName ?? "Member"}
</span>
{manageable && isGroup && !m.isPrimary && (
<Button
variant="ghost"
size="icon-sm"
aria-label="Remove member"
onClick={() => onRemoveMember(summary, m.subscriptionMemberId)}
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
))}
</div>
</div>
{/* Payment history */}
<details className="group" onToggle={(e) => (e.currentTarget as HTMLDetailsElement).open && loadPayments()}>
<summary className="cursor-pointer list-none text-xs font-medium text-primary">
Payment history
</summary>
<div className="mt-1.5 flex flex-col gap-1">
{loadingPayments ? (
<Skeleton className="h-8 rounded-lg" />
) : !payments || payments.length === 0 ? (
<p className="text-xs text-muted-foreground">No payments yet.</p>
) : (
payments.map((p) => (
<div key={p.attemptId} className="flex items-center justify-between rounded-lg bg-muted/50 px-2.5 py-1.5 text-sm">
<span className="leading-tight">
{formatCurrency(p.payAmount)}
{p.payNote && <span className="ml-1.5 text-xs text-muted-foreground">{p.payNote}</span>}
</span>
<span className="text-xs text-muted-foreground">
{p.payDate ? formatDateTime(p.payDate) : "—"}
</span>
</div>
))
)}
</div>
</details>
{/* Extra fees (e.g. coaching, once or every renewal) */}
<details className="group" onToggle={(e) => (e.currentTarget as HTMLDetailsElement).open && loadExtraFees()}>
<summary className="cursor-pointer list-none text-xs font-medium text-primary">
Extra fees
</summary>
<div className="mt-1.5 flex flex-col gap-1.5">
{loadingFees ? (
<Skeleton className="h-8 rounded-lg" />
) : !displayedExtraFees || displayedExtraFees.length === 0 ? (
<p className="text-xs text-muted-foreground">No extra fees added.</p>
) : (
displayedExtraFees.map((f) => (
<div key={f.customerSubItemId} className="flex items-center justify-between rounded-lg bg-muted/50 px-2.5 py-1.5 text-sm">
<span className="leading-tight">
{f.subItemName}
<span className="ml-1.5 text-xs text-muted-foreground">
{f.frequency === "Recurring" ? "every renewal" : "one-time"}
</span>
</span>
<span className="text-xs font-medium">{formatCurrency(f.subItemPrice)}</span>
</div>
))
)}
{manageable && (
addingFee ? (
<div className="flex flex-col gap-2 rounded-lg border p-2">
<Input placeholder="Fee name (e.g. Coaching fee)" value={feeName} onChange={(e) => setFeeName(e.target.value)} />
<div className="flex gap-2">
<Input
type="number"
placeholder="Amount"
value={feeAmount}
onChange={(e) => setFeeAmount(e.target.value)}
/>
<Select value={feeFrequency} onValueChange={(v) => setFeeFrequency((v as ExtraFeeFrequency) ?? "OneTime")}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OneTime">Once</SelectItem>
<SelectItem value="Recurring">Every renewal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" onClick={() => setAddingFee(false)}>
Cancel
</Button>
<Button size="sm" onClick={() => void handleAddExtraFee()} disabled={savingFee || !feeName || !feeAmount}>
{savingFee && <Spinner className="size-4" />}
Add & invoice
</Button>
</div>
</div>
) : (
<Button variant="ghost" size="sm" className="h-7 w-fit px-2" onClick={() => setAddingFee(true)}>
<PlusIcon className="size-3.5" />
Add extra fee
</Button>
)
)}
</div>
</details>
{/* Payment actions */}
{manageable && summary.balance > 0 && (
<div className="flex flex-col gap-1.5">
{summary.autoChargeEnabled && summary.autoChargeCard ? (
<Button variant="outline" className="w-full" onClick={handleChargeCard} disabled={chargingBusy}>
{chargingBusy ? <Spinner className="size-4" /> : <ZapIcon className="size-4" />}
Charge {summary.autoChargeCard.cardType ?? "card"} •••• {summary.autoChargeCard.cardLast4 ?? "----"}
</Button>
) : (
<p className="text-xs text-muted-foreground">
Member hasn&apos;t added a card with auto-deduct consent yet (done from their mobile app).
</p>
)}
<Button variant="outline" className="w-full" onClick={handleSendLink} disabled={linkBusy}>
{linkBusy ? <Spinner className="size-4" /> : <SendIcon className="size-4" />}
Send payment link
</Button>
<Button className="w-full" onClick={() => onRecordPayment(summary)}>
<WalletIcon className="size-4" />
Mark as paid{nextUnpaid ? ` — ${formatCurrency(nextUnpaid.amount)}` : ""}
</Button>
</div>
)}
</div>
)
}
export { MemberSubscriptionCard }
@@ -0,0 +1,72 @@
"use client"
import * as React from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { formatDate } from "@/lib/format"
import type { CustomerTarget } from "@/lib/models/customer"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const chartConfig = {
weight: {
label: "Weight (kg)",
color: "var(--primary)",
},
} satisfies ChartConfig
/** Flattens every weigh-in across a member's targets into a single sorted series. */
function buildSeries(targets?: CustomerTarget[] | null) {
const points = (targets ?? [])
.flatMap((t) => t.weeklyProgresses ?? [])
.filter((p) => p.weight != null)
.map((p) => ({ recordedAt: p.recordedAt, weight: Number(p.weight) }))
.sort((a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime())
return points.map((p) => ({ label: formatDate(p.recordedAt), weight: p.weight }))
}
/** True if the member has at least one recorded weigh-in — callers use this to hide the whole section when there's nothing to show. */
export function hasWeightProgress(targets?: CustomerTarget[] | null): boolean {
return buildSeries(targets).length > 0
}
interface MemberWeightChartProps {
targets?: CustomerTarget[] | null
className?: string
}
export function MemberWeightChart({ targets, className }: MemberWeightChartProps) {
const data = React.useMemo(() => buildSeries(targets), [targets])
if (data.length < 2) {
return (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed text-xs text-muted-foreground">
Not enough weigh-ins yet to chart progress.
</div>
)
}
return (
<ChartContainer config={chartConfig} className={className ?? "h-32 w-full"}>
<LineChart data={data} margin={{ left: 4, right: 8, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={6} tick={{ fontSize: 10 }} />
<YAxis width={28} tickLine={false} axisLine={false} tick={{ fontSize: 10 }} domain={["dataMin - 2", "dataMax + 2"]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line
dataKey="weight"
type="monotone"
stroke="var(--color-weight)"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 4 }}
/>
</LineChart>
</ChartContainer>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
"use client"
import { GymPlansView } from "@/components/hexa/views/gym-plans-view"
import { PlacesView } from "@/components/hexa/views/places-view"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
function MembershipsView() {
return (
<Tabs defaultValue="plans" className="gap-4">
<TabsList>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="places">Places</TabsTrigger>
</TabsList>
<TabsContent value="plans">
<GymPlansView />
</TabsContent>
<TabsContent value="places">
<PlacesView />
</TabsContent>
</Tabs>
)
}
export { MembershipsView }
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { SendIcon, WalletIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { useMediaQuery } from "@/lib/use-media-query"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
interface PaymentQuickActionsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
memberName?: string
/** Amount to show on the "Mark as paid" button (next unpaid installment, or full balance). */
amount: number
sendingLink: boolean
onSendLink: () => void
onMarkPaid: () => void
}
function PaymentQuickActionsDialog({
open,
onOpenChange,
memberName,
amount,
sendingLink,
onSendLink,
onMarkPaid,
}: PaymentQuickActionsDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const actions = (
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full" onClick={onSendLink} disabled={sendingLink}>
{sendingLink ? <Spinner className="size-4" /> : <SendIcon className="size-4" />}
Send payment link
</Button>
<Button className="w-full" onClick={onMarkPaid}>
<WalletIcon className="size-4" />
Mark as paid — {formatCurrency(amount)}
</Button>
</div>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Payment actions</DialogTitle>
<DialogDescription>{memberName ?? "Collect the outstanding balance."}</DialogDescription>
</DialogHeader>
{actions}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>Payment actions</SheetTitle>
<SheetDescription>{memberName ?? "Collect the outstanding balance."}</SheetDescription>
</SheetHeader>
<div className="px-4 pb-4">{actions}</div>
</SheetContent>
</Sheet>
)
}
export { PaymentQuickActionsDialog }
@@ -0,0 +1,19 @@
import { ConstructionIcon } from "lucide-react"
import { PageHeader } from "@/components/hexa/page-header"
function PlaceholderView({ title }: { title: string }) {
return (
<div className="flex flex-col gap-4">
<PageHeader title={title} description="This screen hasn't been built yet in this component pass." />
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed bg-card py-20 text-center">
<ConstructionIcon className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Coming soon — built from the same component kit as the other screens.
</p>
</div>
</div>
)
}
export { PlaceholderView }
+378
View File
@@ -0,0 +1,378 @@
"use client"
import * as React from "react"
import { CheckIcon, MapPinIcon, PencilIcon, PlusIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { getAccessDevices } from "@/lib/api/access-devices"
import { assignPlaceDevice, createPlace, getPlaces, setPlaceStatus, updatePlace } from "@/lib/api/places"
import type { Place } from "@/lib/models/place"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
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 { Textarea } from "@/components/ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { InfoBanner } from "@/components/hexa/info-banner"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
interface PlaceFormValues {
name: string
description: string
}
const EMPTY_VALUES: PlaceFormValues = { name: "", description: "" }
interface PlaceDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: PlaceFormValues) => Promise<void> | void
mode: "create" | "edit"
initialValues?: Partial<PlaceFormValues>
}
function PlaceDialog({ open, onOpenChange, onSubmit, mode, initialValues }: PlaceDialogProps) {
const [values, setValues] = React.useState<PlaceFormValues>(EMPTY_VALUES)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) setValues({ ...EMPTY_VALUES, ...initialValues })
}
const canSubmit = !!values.name.trim() && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() => Promise.resolve(onSubmit(values)))
onOpenChange(false)
} 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>{mode === "create" ? "Add Place" : "Edit Place"}</DialogTitle>
<DialogDescription>
A physical access zone (e.g. &quot;Pool Area&quot;, &quot;Indoor Gym&quot;).
Assign its fingerprint terminal from the table after creating it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="place-name"
placeholder="e.g. Pool Area"
value={values.name}
onChange={(e) => setValues((v) => ({ ...v, name: e.target.value }))}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-description">Description</Label>
<Textarea
id="place-description"
placeholder="Optional notes"
maxLength={300}
value={values.description}
onChange={(e) => setValues((v) => ({ ...v, description: e.target.value }))}
/>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Add Place" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function PlacesView() {
const placesFetch = useFetch(() => getPlaces(true))
const places = placesFetch.data ?? []
const devicesFetch = useFetch(() => getAccessDevices())
const devices = devicesFetch.data?.devices ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [dialogMode, setDialogMode] = React.useState<"create" | "edit">("create")
const [editTarget, setEditTarget] = React.useState<Place | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
const [devicePendingId, setDevicePendingId] = React.useState<string | null>(null)
async function handleCreate(values: PlaceFormValues) {
await createPlace({ name: values.name.trim(), description: values.description.trim() || undefined })
toast.success("Place created", { description: `"${values.name}" was added.` })
placesFetch.reload()
}
async function handleEdit(values: PlaceFormValues) {
if (!editTarget) return
await updatePlace(editTarget.placeId, {
name: values.name.trim(),
description: values.description.trim() || undefined,
})
toast.success("Place updated")
placesFetch.reload()
}
function startEdit(place: Place) {
setDialogMode("edit")
setEditTarget(place)
setDialogOpen(true)
}
async function toggleStatus(place: Place) {
setStatusPendingId(place.placeId)
try {
await runMutation(() => setPlaceStatus(place.placeId, !place.isActive))
toast.success(place.isActive ? "Place deactivated" : "Place activated")
placesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
async function changeDevice(place: Place, deviceId: string) {
setDevicePendingId(place.placeId)
try {
await runMutation(() => assignPlaceDevice(place.placeId, deviceId === "none" ? null : deviceId))
toast.success("Device assignment updated")
placesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setDevicePendingId(null)
}
}
const assignedDeviceIds = new Set(places.map((p) => p.deviceId).filter((id): id is string => !!id))
const columns: DataTableColumn<Place>[] = [
{
key: "name",
header: "Place",
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">
<MapPinIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && <p className="text-sm text-muted-foreground">{row.description}</p>}
</div>
</div>
),
},
{
key: "device",
header: "Fingerprint machine",
cell: (row) => (
<Select
items={[{ label: "Unassigned", value: "none" }, ...devices.map((d) => ({ label: d.deviceName, value: d.deviceId }))]}
value={row.deviceId ?? "none"}
onValueChange={(v) => changeDevice(row, v ?? "none")}
>
<SelectTrigger className="w-56" disabled={devicePendingId === row.placeId}>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Unassigned</SelectItem>
{devices.map((d) => (
<SelectItem
key={d.deviceId}
value={d.deviceId}
disabled={d.deviceId !== row.deviceId && assignedDeviceIds.has(d.deviceId)}
>
{d.deviceName}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
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 place" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit place</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate place" : "Activate place"}
disabled={statusPendingId === row.placeId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.placeId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
) : (
<XIcon className="text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate place" : "Activate place"}</TooltipContent>
</Tooltip>
</div>
),
},
]
if (placesFetch.loading) {
return <PageSkeleton />
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Places</h2>
<p className="text-sm text-muted-foreground">
Physical access zones, each tied to one fingerprint machine.
</p>
</div>
<Button
onClick={() => {
setDialogMode("create")
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Place
</Button>
</div>
<DataTable
columns={columns}
rows={places}
rowKey={(row) => row.placeId}
emptyMessage="No places yet. Add one (e.g. Pool Area, Indoor Gym) 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">
<MapPinIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && <p className="text-sm text-muted-foreground">{row.description}</p>}
</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">
<Badge variant="outline">{row.deviceName ?? "No device assigned"}</Badge>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit place" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate place" : "Activate place"}
disabled={statusPendingId === row.placeId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.placeId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
) : (
<XIcon className="text-muted-foreground" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<InfoBanner>
Members reach only the machine(s) belonging to their plan&apos;s places. A plan with no
places selected stays unrestricted (every terminal).
</InfoBanner>
<PlaceDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
mode={dialogMode}
initialValues={
dialogMode === "edit" && editTarget
? { name: editTarget.name, description: editTarget.description ?? "" }
: undefined
}
/>
</div>
)
}
export { PlacesView }
@@ -0,0 +1,217 @@
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { toast } from "sonner"
import type { PlanItemView, TrainingPlan } from "@/lib/models/plan"
import { runMutation } from "@/lib/use-mutation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
import { DetailPanel } from "@/components/hexa/detail-panel"
interface PlanDetailPanelProps {
plan: TrainingPlan | null
open: boolean
onOpenChange: (open: boolean) => void
kind: "workout" | "meal"
getItems: (planId: string) => Promise<PlanItemView[]>
createItem: (planId: string, title: string, detail: string) => Promise<unknown>
}
function PlanDetailPanel({ plan, open, onOpenChange, kind, getItems, createItem }: PlanDetailPanelProps) {
const [items, setItems] = React.useState<PlanItemView[]>([])
const [loading, setLoading] = React.useState(false)
const [adding, setAdding] = React.useState(false)
const [title, setTitle] = React.useState("")
const [detail, setDetail] = React.useState("")
const [saving, setSaving] = React.useState(false)
const titleLabel = kind === "workout" ? "Exercise day" : "Meal"
const detailLabel = kind === "workout" ? "Exercises" : "Foods"
const titlePlaceholder = kind === "workout" ? "e.g. Monday" : "e.g. Breakfast"
const detailPlaceholder =
kind === "workout" ? "e.g. Bench press, Squats, Deadlift" : "e.g. Oats, Eggs, Banana"
const load = React.useCallback(
async (planId: string) => {
setLoading(true)
try {
setItems(await getItems(planId))
} catch {
toast.error("Couldn't load the plan items.")
} finally {
setLoading(false)
}
},
[getItems]
)
const planId = plan?.planId
React.useEffect(() => {
if (!open || !planId) return
void Promise.resolve().then(async () => {
setAdding(false)
setTitle("")
setDetail("")
await load(planId)
})
}, [open, planId, load])
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!plan || !title.trim()) return
setSaving(true)
try {
await runMutation(() => createItem(plan.planId, title.trim(), detail.trim()))
toast.success(kind === "workout" ? "Workout day added" : "Meal added")
setTitle("")
setDetail("")
setAdding(false)
await load(plan.planId)
} catch {
// runMutation() already toasted the real error.
} finally {
setSaving(false)
}
}
return (
<DetailPanel open={open} onOpenChange={onOpenChange} title={plan?.name ?? ""} description={plan?.categoryName ?? undefined}>
<div className="flex flex-col gap-4 py-1">
{plan?.description && <p className="text-sm text-muted-foreground">{plan.description}</p>}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{kind === "workout" ? "Workout days" : "Meals"}
{!loading && items.length > 0 && (
<Badge variant="outline" className="ml-2">
{items.length}
</Badge>
)}
</h3>
{!adding && (
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<PlusIcon />
Add {kind === "workout" ? "day" : "meal"}
</Button>
)}
</div>
{adding && (
<form onSubmit={handleAdd} className="flex flex-col gap-2.5 rounded-lg border p-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="item-title">{titleLabel}</Label>
<Input
id="item-title"
placeholder={titlePlaceholder}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="item-detail">{detailLabel}</Label>
<Textarea
id="item-detail"
placeholder={detailPlaceholder}
value={detail}
onChange={(e) => setDetail(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setAdding(false)}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={!title.trim() || saving}>
{saving && <Spinner className="size-4" />}
Add
</Button>
</div>
</form>
)}
{loading ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
) : items.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
No {kind === "workout" ? "workout days" : "meals"} yet. Add them one by one.
</p>
) : (
<div className="flex flex-col gap-2">
{items.map((item, i) => (
<div key={item.id} className="flex flex-col gap-1.5 rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{i + 1}
</span>
<p className="font-medium">{item.title}</p>
{item.exercises.length > 0 && (
<Badge variant="outline" className="ml-auto">
{item.exercises.length} exercise{item.exercises.length === 1 ? "" : "s"}
</Badge>
)}
</div>
{item.detail && (
<p className="pl-8 text-sm whitespace-pre-wrap text-muted-foreground">{item.detail}</p>
)}
{item.exercises.length > 0 && (
<div className="flex flex-col gap-1.5 pl-8">
{item.exercises.map((exercise) => (
<div key={exercise.id} className="rounded-md border bg-muted/30 p-2 text-sm">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{exercise.name}</span>
{exercise.muscleGroup && (
<Badge variant="secondary" className="text-[10px]">
{exercise.muscleGroup}
</Badge>
)}
{(exercise.sets || exercise.reps) && (
<span className="text-xs text-muted-foreground">
{exercise.sets ?? "—"} sets × {exercise.reps ?? "—"} reps
</span>
)}
</div>
{exercise.description && (
<p className="mt-1 text-xs text-muted-foreground">{exercise.description}</p>
)}
{(exercise.breathIn || exercise.breathOut) && (
<p className="mt-1 text-xs text-muted-foreground">
{exercise.breathIn && <>Inhale: {exercise.breathIn} </>}
{exercise.breathOut && <>· Exhale: {exercise.breathOut}</>}
</p>
)}
{exercise.videoUrl && (
<a
href={exercise.videoUrl}
target="_blank"
rel="noreferrer"
className="mt-1 inline-block text-xs font-medium text-primary hover:underline"
>
Watch video
</a>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</DetailPanel>
)
}
export { PlanDetailPanel }
@@ -0,0 +1,418 @@
"use client"
import * as React from "react"
import { PlusIcon, XIcon } from "lucide-react"
import type { PlanCategory } from "@/lib/models/plan"
import { useMutation } from "@/lib/use-mutation"
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 { Textarea } from "@/components/ui/textarea"
export interface ExerciseDraft {
name: string
muscleGroup: string
sets: string
reps: string
description: string
breathIn: string
breathOut: string
videoUrl: string
}
export interface PlanItemDraft {
label: string
detail: string
/** Structured exercises for a workout day. Ignored for meal plans. */
exercises: ExerciseDraft[]
}
export interface PlanEditorValues {
name: string
description: string
categoryId: string
items: PlanItemDraft[]
}
export interface PlanEditorSubmit {
name: string
description?: string
categoryId: string
items: PlanItemDraft[]
}
interface PlanEditorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: PlanEditorSubmit) => Promise<void>
categories: PlanCategory[]
mode: "create" | "edit"
/** "workout" tweaks labels (Exercise Day / Exercises); "meal" uses Meal / Foods. */
kind: "workout" | "meal"
initialValues?: Partial<PlanEditorValues>
}
const EMPTY_EXERCISE: ExerciseDraft = {
name: "",
muscleGroup: "",
sets: "",
reps: "",
description: "",
breathIn: "",
breathOut: "",
videoUrl: "",
}
const EMPTY: PlanEditorValues = { name: "", description: "", categoryId: "", items: [] }
function PlanEditorDialog({
open,
onOpenChange,
onSubmit,
categories,
mode,
kind,
initialValues,
}: PlanEditorDialogProps) {
const [values, setValues] = React.useState<PlanEditorValues>(EMPTY)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setValues({ ...EMPTY, ...initialValues, items: initialValues?.items ?? [] })
}
}
const isWorkout = kind === "workout"
const labelName = isWorkout ? "Exercise day" : "Meal"
const detailName = isWorkout ? "Day notes" : "Foods"
const labelPlaceholder = isWorkout ? "e.g. Day 1 - Push" : "e.g. Breakfast"
const detailPlaceholder = isWorkout
? "Optional notes about this day (warm-up focus, intensity, etc.)"
: "e.g. Oats, Eggs, Banana"
const canSubmit = values.name.trim() && values.categoryId && !busy
function addItem() {
setValues((v) => ({ ...v, items: [...v.items, { label: "", detail: "", exercises: [] }] }))
}
function updateItem(index: number, patch: Partial<PlanItemDraft>) {
setValues((v) => ({
...v,
items: v.items.map((it, i) => (i === index ? { ...it, ...patch } : it)),
}))
}
function removeItem(index: number) {
setValues((v) => ({ ...v, items: v.items.filter((_, i) => i !== index) }))
}
function addExercise(itemIndex: number) {
setValues((v) => ({
...v,
items: v.items.map((it, i) =>
i === itemIndex ? { ...it, exercises: [...it.exercises, { ...EMPTY_EXERCISE }] } : it
),
}))
}
function updateExercise(itemIndex: number, exerciseIndex: number, patch: Partial<ExerciseDraft>) {
setValues((v) => ({
...v,
items: v.items.map((it, i) =>
i === itemIndex
? {
...it,
exercises: it.exercises.map((ex, j) => (j === exerciseIndex ? { ...ex, ...patch } : ex)),
}
: it
),
}))
}
function removeExercise(itemIndex: number, exerciseIndex: number) {
setValues((v) => ({
...v,
items: v.items.map((it, i) =>
i === itemIndex ? { ...it, exercises: it.exercises.filter((_, j) => j !== exerciseIndex) } : it
),
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() =>
onSubmit({
name: values.name.trim(),
description: values.description.trim() || undefined,
categoryId: values.categoryId,
items: values.items.filter((it) => it.label.trim()),
})
)
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open so the user can retry.
}
}
const categoryItems = categories.map((c) => ({ label: c.categoryName, value: c.categoryId }))
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[92vh] flex-col overflow-y-auto sm:max-w-2xl">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Create" : "Edit"} {isWorkout ? "Workout" : "Meal"} Plan
</DialogTitle>
<DialogDescription>
Build a structured plan your members can follow from their phone.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-name">
Plan Name <span className="text-destructive">*</span>
</Label>
<Input
id="plan-name"
placeholder={isWorkout ? "e.g. Beginner Full Body" : "e.g. Lean Bulk 2500kcal"}
value={values.name}
onChange={(e) => setValues((v) => ({ ...v, name: e.target.value }))}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>
Category <span className="text-destructive">*</span>
</Label>
{categoryItems.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
No categories yet. Create one from the page header first.
</p>
) : (
<Select
items={categoryItems}
value={values.categoryId || null}
onValueChange={(v) => setValues((val) => ({ ...val, categoryId: v ?? "" }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categoryItems.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="plan-desc">Description</Label>
<Textarea
id="plan-desc"
placeholder="What is this plan for?"
maxLength={300}
value={values.description}
onChange={(e) => setValues((v) => ({ ...v, description: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label>{isWorkout ? "Workout days" : "Meals"}</Label>
<Button type="button" variant="outline" size="sm" onClick={addItem}>
<PlusIcon />
Add {isWorkout ? "day" : "meal"}
</Button>
</div>
{values.items.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
No {isWorkout ? "days" : "meals"} added yet. You can add these later too.
</p>
) : (
<div className="flex flex-col gap-3">
{values.items.map((item, index) => (
<div key={index} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Input
placeholder={labelPlaceholder}
aria-label={labelName}
value={item.label}
onChange={(e) => updateItem(index, { label: e.target.value })}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remove"
onClick={() => removeItem(index)}
>
<XIcon />
</Button>
</div>
{isWorkout ? (
<Input
placeholder={detailPlaceholder}
aria-label={detailName}
value={item.detail}
onChange={(e) => updateItem(index, { detail: e.target.value })}
/>
) : (
<Textarea
placeholder={detailPlaceholder}
aria-label={detailName}
value={item.detail}
onChange={(e) => updateItem(index, { detail: e.target.value })}
/>
)}
{isWorkout && (
<div className="flex flex-col gap-2 rounded-md bg-muted/40 p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Exercises</span>
<Button type="button" variant="outline" size="sm" onClick={() => addExercise(index)}>
<PlusIcon />
Add exercise
</Button>
</div>
{item.exercises.length === 0 ? (
<p className="rounded-md border border-dashed px-2 py-3 text-center text-xs text-muted-foreground">
No exercises yet.
</p>
) : (
item.exercises.map((exercise, exerciseIndex) => (
<div key={exerciseIndex} className="flex flex-col gap-1.5 rounded-md border bg-background p-2.5">
<div className="flex items-center gap-2">
<Input
placeholder="Exercise name (e.g. Barbell Squat)"
aria-label="Exercise name"
value={exercise.name}
onChange={(e) => updateExercise(index, exerciseIndex, { name: e.target.value })}
/>
<Input
className="w-36"
placeholder="Muscle group"
aria-label="Muscle group"
value={exercise.muscleGroup}
onChange={(e) => updateExercise(index, exerciseIndex, { muscleGroup: e.target.value })}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remove exercise"
onClick={() => removeExercise(index, exerciseIndex)}
>
<XIcon />
</Button>
</div>
<div className="flex items-center gap-2">
<Input
className="w-20"
type="number"
min={0}
placeholder="Sets"
aria-label="Sets"
value={exercise.sets}
onChange={(e) => updateExercise(index, exerciseIndex, { sets: e.target.value })}
/>
<Input
className="w-28"
placeholder="Reps (e.g. 8-12)"
aria-label="Reps"
value={exercise.reps}
onChange={(e) => updateExercise(index, exerciseIndex, { reps: e.target.value })}
/>
<Input
placeholder="Video URL"
aria-label="Video URL"
value={exercise.videoUrl}
onChange={(e) => updateExercise(index, exerciseIndex, { videoUrl: e.target.value })}
/>
</div>
<Textarea
placeholder="Description / how to perform"
aria-label="Exercise description"
value={exercise.description}
onChange={(e) => updateExercise(index, exerciseIndex, { description: e.target.value })}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Breathe in cue"
aria-label="Breathe in"
value={exercise.breathIn}
onChange={(e) => updateExercise(index, exerciseIndex, { breathIn: e.target.value })}
/>
<Input
placeholder="Breathe out cue"
aria-label="Breathe out"
value={exercise.breathOut}
onChange={(e) => updateExercise(index, exerciseIndex, { breathOut: e.target.value })}
/>
</div>
</div>
))
)}
</div>
)}
</div>
))}
</div>
)}
{mode === "edit" && (
<p className="text-xs text-muted-foreground mb-2">
Saving replaces this plan&apos;s {isWorkout ? "days" : "meals"} with the list above.
</p>
)}
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Create Plan" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { PlanEditorDialog }
@@ -0,0 +1,843 @@
"use client"
import * as React from "react"
import {
ActivityIcon,
CircleCheckIcon,
EyeIcon,
LayersIcon,
PencilIcon,
PlusIcon,
SearchIcon,
Trash2Icon,
UsersIcon,
XIcon,
} from "lucide-react"
import { toast } from "sonner"
import { getMembers } from "@/lib/api/customers"
import {
DEMO_MEAL_CATEGORIES,
DEMO_MEAL_TRAINING_PLANS,
DEMO_MEMBER_PROFILES,
DEMO_PLAN_ITEMS,
DEMO_WORKOUT_CATEGORIES,
DEMO_WORKOUT_TRAINING_PLANS,
} from "@/lib/demo/mock-data"
import type { MemberSubscription } from "@/lib/models/customer"
import type { AssignedCustomer, CreatePlanInput, PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
import { sortByNewest } from "@/lib/validation"
import { useFetch } from "@/lib/use-fetch"
import { 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 { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { DetailPanel } from "@/components/hexa/detail-panel"
import { FilterBar } from "@/components/hexa/filter-bar"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatCard } from "@/components/hexa/stat-card"
import { StatusBadge } from "@/components/hexa/status-badge"
import {
PlanEditorDialog,
type PlanEditorSubmit,
type PlanItemDraft,
} from "@/components/hexa/views/plan-editor-dialog"
import { AddCategoryDialog } from "@/components/hexa/views/add-category-dialog"
import { PlanDetailPanel } from "@/components/hexa/views/plan-detail-panel"
/** Maps the generic editor items to the workout/meal payload the service expects. */
export type PlanItemMapper = (items: PlanItemDraft[]) => unknown[]
interface PlanManagementViewProps {
kind: "workout" | "meal"
title: string
description: string
getPlans: () => Promise<TrainingPlan[]>
getCategories: () => Promise<PlanCategory[]>
createCategory: (name: string, description?: string) => Promise<unknown>
createPlan: (input: CreatePlanInput & Record<string, unknown>) => Promise<unknown>
updatePlan: (id: string, input: Partial<CreatePlanInput> & Record<string, unknown>) => Promise<unknown>
deletePlan: (id: string) => Promise<unknown>
assignPlan: (id: string, customerId: string, notes?: string) => Promise<unknown>
unassignPlan: (assignmentId: string) => Promise<unknown>
getItems: (planId: string) => Promise<PlanItemView[]>
createItem: (planId: string, title: string, detail: string) => Promise<unknown>
mapItems: PlanItemMapper
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function PlanManagementView({
kind,
title,
description,
getPlans,
getCategories,
createCategory,
createPlan,
updatePlan,
deletePlan,
assignPlan,
unassignPlan,
getItems,
createItem,
mapItems,
demo = false,
}: PlanManagementViewProps) {
const plansFetch = useFetch(demo ? async () => [] as TrainingPlan[] : getPlans)
const categoriesFetch = useFetch(demo ? async () => [] as PlanCategory[] : getCategories)
const membersFetch = useFetch(demo ? async () => [] as MemberSubscription[] : getMembers)
const [demoPlans, setDemoPlans] = React.useState<TrainingPlan[]>(
kind === "workout" ? DEMO_WORKOUT_TRAINING_PLANS : DEMO_MEAL_TRAINING_PLANS
)
const [demoCategories, setDemoCategories] = React.useState<PlanCategory[]>(
kind === "workout" ? DEMO_WORKOUT_CATEGORIES : DEMO_MEAL_CATEGORIES
)
const [demoItems, setDemoItems] = React.useState<Record<string, PlanItemView[]>>(DEMO_PLAN_ITEMS)
const plans = React.useMemo(
() => sortByNewest(demo ? demoPlans : (plansFetch.data ?? []), (p) => p.createDate),
[demo, demoPlans, plansFetch.data]
)
const categories = React.useMemo(
() => (demo ? demoCategories : (categoriesFetch.data ?? [])),
[demo, demoCategories, categoriesFetch.data]
)
const [search, setSearch] = React.useState("")
const [editorOpen, setEditorOpen] = React.useState(false)
const [editorMode, setEditorMode] = React.useState<"create" | "edit">("create")
const [editTarget, setEditTarget] = React.useState<TrainingPlan | null>(null)
// Items loaded for the plan currently being edited, so the editor opens
// pre-populated with the existing days/meals instead of a blank list.
const [editItems, setEditItems] = React.useState<PlanItemDraft[]>([])
const [loadingEditId, setLoadingEditId] = React.useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = React.useState<TrainingPlan | null>(null)
const [deleting, setDeleting] = React.useState(false)
// Stored by id (not the plan object) so the panel reflects fresh assignedCustomers
// after an assign/unassign triggers a reload, instead of showing a stale snapshot.
const [assignTargetId, setAssignTargetId] = React.useState<string | null>(null)
const [assignCustomer, setAssignCustomer] = React.useState<string>("")
const [assigning, setAssigning] = React.useState(false)
const [assignedSearch, setAssignedSearch] = React.useState("")
const [memberSearch, setMemberSearch] = React.useState("")
// Confirming unassign opens an AlertDialog, which (per the app-wide exclusive-modal
// stack) force-closes the "Assign to member" DetailPanel underneath it. Keep the
// plan id here so it can be reopened once the confirmation is resolved.
const [unassignTarget, setUnassignTarget] = React.useState<{ planId: string; customer: AssignedCustomer } | null>(null)
const [unassigningId, setUnassigningId] = React.useState<string | null>(null)
const assignTarget = plans.find((p) => p.planId === assignTargetId) ?? null
const [categoryDialogOpen, setCategoryDialogOpen] = React.useState(false)
const [viewTarget, setViewTarget] = React.useState<TrainingPlan | null>(null)
const filtered = plans.filter(
(p) =>
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.categoryName ?? "").toLowerCase().includes(search.toLowerCase())
)
const activeCount = plans.filter((p) => p.isActive).length
const templateCount = plans.filter((p) => p.isTemplate).length
const totalAssignments = plans.reduce((sum, p) => sum + p.assignedCustomers.length, 0)
/** Sandbox-only: mirrors what createItem/PlanEditorDialog would persist for one plan's days/meals. */
function draftsToItemViews(planId: string, drafts: PlanItemDraft[]): PlanItemView[] {
return drafts
.filter((it) => it.label.trim())
.map((it, i) => ({
id: `item-${planId}-${i}`,
title: it.label,
detail: it.detail,
exercises: it.exercises
.filter((ex) => ex.name.trim())
.map((ex, j) => ({
id: `ex-${planId}-${i}-${j}`,
name: ex.name,
muscleGroup: ex.muscleGroup || null,
sets: ex.sets.trim() ? Number(ex.sets) : null,
reps: ex.reps || null,
description: ex.description || null,
breathIn: ex.breathIn || null,
breathOut: ex.breathOut || null,
videoUrl: ex.videoUrl || null,
})),
}))
}
async function demoGetItems(planId: string): Promise<PlanItemView[]> {
return demoItems[planId] ?? []
}
async function demoCreateItem(planId: string, title: string, detailText: string): Promise<unknown> {
setDemoItems((prev) => ({
...prev,
[planId]: [...(prev[planId] ?? []), { id: crypto.randomUUID(), title, detail: detailText, exercises: [] }],
}))
return {}
}
async function handleCreateCategory(name: string, description?: string) {
if (demo) {
setDemoCategories((prev) => [...prev, { categoryId: crypto.randomUUID(), categoryName: name, description: description ?? null }])
toast.success("Category created")
return
}
await createCategory(name, description)
toast.success("Category created")
categoriesFetch.reload()
}
async function handleSubmit(values: PlanEditorSubmit) {
if (demo) {
const category = demoCategories.find((c) => c.categoryId === values.categoryId)
if (editorMode === "create") {
const planId = crypto.randomUUID()
setDemoPlans((prev) => [
{
planId,
name: values.name,
description: values.description,
isTemplate: false,
categoryId: values.categoryId,
categoryName: category?.categoryName ?? null,
isActive: true,
createDate: new Date().toISOString(),
assignedCustomers: [],
},
...prev,
])
setDemoItems((prev) => ({ ...prev, [planId]: draftsToItemViews(planId, values.items) }))
toast.success("Plan created", { description: `"${values.name}" was added.` })
} else if (editTarget) {
setDemoPlans((prev) =>
prev.map((p) =>
p.planId === editTarget.planId
? { ...p, name: values.name, description: values.description, categoryId: values.categoryId, categoryName: category?.categoryName ?? p.categoryName }
: p
)
)
setDemoItems((prev) => ({ ...prev, [editTarget.planId]: draftsToItemViews(editTarget.planId, values.items) }))
toast.success("Plan updated")
}
return
}
const items = mapItems(values.items)
if (editorMode === "create") {
await createPlan({
name: values.name,
description: values.description,
categoryId: values.categoryId,
[kind === "workout" ? "workoutItems" : "mealItems"]: items,
})
toast.success("Plan created", { description: `"${values.name}" was added.` })
} else if (editTarget) {
await updatePlan(editTarget.planId, {
name: values.name,
description: values.description,
categoryId: values.categoryId,
[kind === "workout" ? "workoutItems" : "mealItems"]: items,
})
toast.success("Plan updated")
}
plansFetch.reload()
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
if (demo) {
setDemoPlans((prev) => prev.filter((p) => p.planId !== deleteTarget.planId))
toast("Plan deleted")
setDeleteTarget(null)
setDeleting(false)
return
}
try {
await runMutation(() => deletePlan(deleteTarget.planId))
toast("Plan deleted")
setDeleteTarget(null)
plansFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setDeleting(false)
}
}
async function handleAssign() {
if (!assignTarget || !assignCustomer) return
setAssigning(true)
if (demo) {
const member = DEMO_MEMBER_PROFILES.find((c) => c.cusId === assignCustomer)
setDemoPlans((prev) =>
prev.map((p) =>
p.planId === assignTarget.planId
? {
...p,
assignedCustomers: [
...p.assignedCustomers,
{ assignmentId: crypto.randomUUID(), customerId: assignCustomer, customerName: member?.cusName ?? "Member", isActive: true, assignedDate: new Date().toISOString().slice(0, 10) },
],
}
: p
)
)
toast.success("Plan assigned to member")
setAssignCustomer("")
setAssigning(false)
return
}
try {
await runMutation(() => assignPlan(assignTarget.planId, assignCustomer))
toast.success("Plan assigned to member")
setAssignCustomer("")
plansFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setAssigning(false)
}
}
async function handleUnassign() {
if (!unassignTarget) return
setUnassigningId(unassignTarget.customer.assignmentId)
if (demo) {
setDemoPlans((prev) =>
prev.map((p) =>
p.planId === unassignTarget.planId
? { ...p, assignedCustomers: p.assignedCustomers.filter((a) => a.assignmentId !== unassignTarget.customer.assignmentId) }
: p
)
)
toast.success("Member unassigned")
setAssignTargetId(unassignTarget.planId)
setUnassignTarget(null)
setUnassigningId(null)
return
}
try {
await runMutation(() => unassignPlan(unassignTarget.customer.assignmentId))
toast.success("Member unassigned")
setAssignTargetId(unassignTarget.planId)
setUnassignTarget(null)
plansFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setUnassigningId(null)
}
}
async function startEdit(plan: TrainingPlan) {
// Load the saved items first so the editor opens with them already filled in.
setLoadingEditId(plan.planId)
try {
const items = demo ? await demoGetItems(plan.planId) : await getItems(plan.planId)
setEditItems(
items.map((it) => ({
label: it.title,
detail: it.detail,
exercises: it.exercises.map((ex) => ({
name: ex.name,
muscleGroup: ex.muscleGroup ?? "",
sets: ex.sets != null ? String(ex.sets) : "",
reps: ex.reps ?? "",
description: ex.description ?? "",
breathIn: ex.breathIn ?? "",
breathOut: ex.breathOut ?? "",
videoUrl: ex.videoUrl ?? "",
})),
}))
)
setEditorMode("edit")
setEditTarget(plan)
setEditorOpen(true)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Couldn't load this plan's items.")
} finally {
setLoadingEditId(null)
}
}
const assignedCustomerIds = new Set(
(assignTarget?.assignedCustomers ?? []).map((a) => a.customerId)
)
const allMemberOptions = demo
? DEMO_MEMBER_PROFILES.map((c) => ({ label: c.cusName ?? "Unknown", value: c.cusId }))
: (membersFetch.data ?? [])
.filter((m: MemberSubscription) => m.customer)
.map((m: MemberSubscription) => ({ label: m.customer?.cusName ?? "Unknown", value: m.fkCustomerId }))
const memberOptions = allMemberOptions.filter((opt) => !assignedCustomerIds.has(opt.value))
// Narrows what's shown in the dropdown list only — `memberOptions` itself stays the
// source of truth passed as `items` to <Select>, so the trigger can still resolve the
// selected member's label even if a later search term would filter them out of view.
const filteredMemberOptions = memberOptions.filter((opt) =>
opt.label.toLowerCase().includes(memberSearch.trim().toLowerCase())
)
const assignedRows = assignTarget?.assignedCustomers ?? []
const filteredAssignedRows = assignedRows.filter((a) =>
(a.customerName ?? "").toLowerCase().includes(assignedSearch.trim().toLowerCase())
)
const columns: DataTableColumn<TrainingPlan>[] = [
{
key: "name",
header: "Plan Name",
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">
<ActivityIcon className="size-4" />
</div>
<div className="leading-tight">
<p className="font-medium">{row.name}</p>
<p className="text-xs text-muted-foreground">{row.description ?? "—"}</p>
</div>
</div>
),
},
{
key: "category",
header: "Category",
cell: (row) => <Badge variant="outline">{row.categoryName ?? "—"}</Badge>,
},
{
key: "assigned",
header: "Members",
cell: (row) => (
<span className="text-muted-foreground">{row.assignedCustomers.length}</span>
),
},
{
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="View plan" onClick={() => setViewTarget(row)}>
<EyeIcon />
</Button>
}
/>
<TooltipContent>View plan</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Assign to member"
onClick={() => {
setAssignTargetId(row.planId)
setAssignCustomer("")
setAssignedSearch("")
setMemberSearch("")
}}
>
<UsersIcon />
</Button>
}
/>
<TooltipContent>Assign to member</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Edit plan"
onClick={() => startEdit(row)}
disabled={loadingEditId === row.planId}
>
{loadingEditId === row.planId ? <Spinner className="size-4" /> : <PencilIcon />}
</Button>
}
/>
<TooltipContent>Edit plan</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="destructive"
size="icon-sm"
aria-label="Delete plan"
onClick={() => setDeleteTarget(row)}
>
<Trash2Icon />
</Button>
}
/>
<TooltipContent>Delete plan</TooltipContent>
</Tooltip>
</div>
),
},
]
if (!demo && plansFetch.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<PageHeader
title={title}
description={description}
action={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCategoryDialogOpen(true)}>
New Category
</Button>
<Button
onClick={() => {
setEditorMode("create")
setEditTarget(null)
setEditorOpen(true)
}}
>
<PlusIcon />
Create Plan
</Button>
</div>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={LayersIcon} tone="blue" label="Total Plans" value={plans.length} hint="All plans" />
<StatCard icon={CircleCheckIcon} tone="green" label="Active" value={activeCount} hint="Currently active" />
<StatCard icon={ActivityIcon} tone="amber" label="Templates" value={templateCount} hint="Reusable templates" />
<StatCard icon={UsersIcon} tone="purple" label="Assignments" value={totalAssignments} hint="Member assignments" />
</div>
<FilterBar
searchLabel="Search Plans"
searchPlaceholder="Search by plan name or category..."
searchValue={search}
onSearchChange={setSearch}
filters={[]}
resultsShowing={filtered.length}
resultsTotal={plans.length}
onClear={() => setSearch("")}
/>
<DataTable
columns={columns}
rows={filtered}
rowKey={(row) => row.planId}
emptyMessage={`No ${kind} plans yet. Create your first plan 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">
<ActivityIcon className="size-4" />
</div>
<div className="leading-tight">
<p className="font-medium">{row.name}</p>
<p className="text-xs text-muted-foreground">{row.categoryName ?? "—"}</p>
</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 text-sm">
<span className="text-muted-foreground">{row.assignedCustomers.length} members</span>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="View plan" onClick={() => setViewTarget(row)}>
<EyeIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Assign to member"
onClick={() => {
setAssignTargetId(row.planId)
setAssignCustomer("")
setAssignedSearch("")
setMemberSearch("")
}}
>
<UsersIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Edit plan"
onClick={() => startEdit(row)}
disabled={loadingEditId === row.planId}
>
{loadingEditId === row.planId ? <Spinner className="size-4" /> : <PencilIcon />}
</Button>
<Button
variant="destructive"
size="icon-sm"
aria-label="Delete plan"
onClick={() => setDeleteTarget(row)}
>
<Trash2Icon />
</Button>
</div>
</div>
</div>
)}
/>
<PlanEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
onSubmit={handleSubmit}
categories={categories}
mode={editorMode}
kind={kind}
initialValues={
editorMode === "edit" && editTarget
? {
name: editTarget.name,
description: editTarget.description ?? "",
categoryId: editTarget.categoryId,
items: editItems,
}
: undefined
}
/>
<AddCategoryDialog
open={categoryDialogOpen}
onOpenChange={setCategoryDialogOpen}
kind={kind}
onCreate={handleCreateCategory}
/>
<PlanDetailPanel
plan={viewTarget}
open={!!viewTarget}
onOpenChange={(open) => !open && setViewTarget(null)}
kind={kind}
getItems={demo ? demoGetItems : getItems}
createItem={demo ? demoCreateItem : createItem}
/>
<DetailPanel
open={!!assignTarget}
onOpenChange={(open) => !open && setAssignTargetId(null)}
title="Assign to member"
description={assignTarget?.name}
>
<div className="flex flex-col gap-4 py-1">
<div className="flex flex-col gap-2 rounded-lg border p-3">
<h3 className="text-sm font-medium">
Currently assigned
{assignedRows.length > 0 && (
<Badge variant="outline" className="ml-2">
{assignedRows.length}
</Badge>
)}
</h3>
{assignedRows.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
No members assigned yet.
</p>
) : (
<>
{assignedRows.length > 5 && (
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={assignedSearch}
onChange={(e) => setAssignedSearch(e.target.value)}
placeholder="Search assigned members..."
className="h-9 pl-8"
/>
</div>
)}
{filteredAssignedRows.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-3 text-center text-sm text-muted-foreground">
No matches.
</p>
) : (
<div className="max-h-52 overflow-y-auto rounded-lg border">
<Table>
<TableBody>
{filteredAssignedRows.map((a) => (
<TableRow key={a.assignmentId} className="border-b-0">
<TableCell className="truncate text-sm font-medium">
{a.customerName ?? "Unknown"}
</TableCell>
<TableCell className="w-9 py-1 pr-1.5 text-right">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Unassign member"
disabled={unassigningId === a.assignmentId}
onClick={() =>
setUnassignTarget({ planId: assignTarget!.planId, customer: a })
}
>
{unassigningId === a.assignmentId ? (
<Spinner className="size-4" />
) : (
<XIcon />
)}
</Button>
}
/>
<TooltipContent>Unassign member</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</>
)}
</div>
<div className="flex flex-col gap-2 rounded-lg border p-3">
<h3 className="text-sm font-medium">Assign a member</h3>
{memberOptions.length === 0 ? (
<p className="text-sm text-muted-foreground">
No members with active plans yet. Add members first.
</p>
) : (
<>
{memberOptions.length > 5 && (
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
placeholder="Search members to assign..."
className="h-9 pl-8"
/>
</div>
)}
<Select
items={memberOptions}
value={assignCustomer || null}
onValueChange={(v) => setAssignCustomer(v ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a member" />
</SelectTrigger>
<SelectContent>
{filteredMemberOptions.length === 0 ? (
<p className="px-2 py-3 text-center text-sm text-muted-foreground">No matches.</p>
) : (
filteredMemberOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
</>
)}
<Button onClick={handleAssign} disabled={!assignCustomer || assigning}>
{assigning && <Spinner className="size-4" />}
Assign Plan
</Button>
</div>
</div>
</DetailPanel>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this plan?</AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.name}&quot; will be removed. Members already assigned keep their copy.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting && <Spinner className="size-4" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!unassignTarget}
onOpenChange={(open) => {
if (open) return
// Cancelling (rather than confirming) still means returning to the panel
// the exclusive-modal stack closed when this confirmation opened.
setAssignTargetId(unassignTarget?.planId ?? null)
setUnassignTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unassign this member?</AlertDialogTitle>
<AlertDialogDescription>
&quot;{unassignTarget?.customer.customerName ?? "This member"}&quot; will lose access to{" "}
&quot;{plans.find((p) => p.planId === unassignTarget?.planId)?.name ?? "this plan"}&quot;.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!unassigningId}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleUnassign} disabled={!!unassigningId}>
{!!unassigningId && <Spinner className="size-4" />}
Unassign
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export { PlanManagementView }
@@ -0,0 +1,378 @@
"use client"
import * as React from "react"
import { PlusIcon, TrendingDownIcon, TrendingUpIcon, Trash2Icon, WalletIcon } from "lucide-react"
import { toast } from "sonner"
import { addLedgerEntry, deleteLedgerEntry, getProfitLoss } from "@/lib/api/ledger"
import { formatCurrency, formatDate } from "@/lib/format"
import { runMutation } from "@/lib/use-mutation"
import { DEMO_PROFIT_LOSS } from "@/lib/demo/mock-data"
import type { AddLedgerEntryInput, LedgerEntry, LedgerEntryType, ProfitLoss } from "@/lib/models/ledger"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field } from "@/components/ui/field"
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { StatCard } from "@/components/hexa/stat-card"
function startOfMonth() {
const d = new Date()
return new Date(d.getFullYear(), d.getMonth(), 1)
}
/** Recomputes totals/category breakdowns from a raw entry list — used for demo filtering. */
function summarizeProfitLossEntries(entries: LedgerEntry[], from: string, to: string): ProfitLoss {
const categoryTotals = (entryType: LedgerEntryType) => {
const totals = new Map<string, number>()
entries
.filter((e) => e.entryType === entryType)
.forEach((e) => totals.set(e.category, (totals.get(e.category) ?? 0) + e.amount))
return [...totals.entries()].map(([category, amount]) => ({ category, amount }))
}
const totalIncome = entries.filter((e) => e.entryType === "Income").reduce((s, e) => s + e.amount, 0)
const totalExpense = entries.filter((e) => e.entryType === "Expense").reduce((s, e) => s + e.amount, 0)
return {
from,
to,
totalIncome,
totalExpense,
netProfit: totalIncome - totalExpense,
incomeByCategory: categoryTotals("Income"),
expenseByCategory: categoryTotals("Expense"),
entries,
}
}
function AddEntryDialog({
open,
onOpenChange,
onSaved,
demo = false,
onDemoSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSaved: () => void
demo?: boolean
onDemoSave?: (input: AddLedgerEntryInput) => void
}) {
const [entryType, setEntryType] = React.useState<LedgerEntryType>("Expense")
const [category, setCategory] = React.useState("")
const [description, setDescription] = React.useState("")
const [amount, setAmount] = React.useState("")
const [saving, setSaving] = React.useState(false)
async function handleSave() {
const amt = Number(amount) || 0
if (!category.trim() || amt <= 0) return
setSaving(true)
try {
const input: AddLedgerEntryInput = {
entryType,
category: category.trim(),
description: description.trim() || undefined,
amount: amt,
}
if (demo) {
onDemoSave?.(input)
toast.success("Ledger entry added.")
setCategory("")
setDescription("")
setAmount("")
setEntryType("Expense")
onSaved()
onOpenChange(false)
return
}
await runMutation(() => addLedgerEntry(input))
toast.success("Ledger entry added.")
setCategory("")
setDescription("")
setAmount("")
setEntryType("Expense")
onSaved()
onOpenChange(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add ledger entry</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<Field>
<Label>Type</Label>
<Select value={entryType} onValueChange={(v) => setEntryType((v as LedgerEntryType) ?? "Expense")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Income">Income</SelectItem>
<SelectItem value="Expense">Expense</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<Label>Category</Label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. Rent, Utilities, Equipment"
/>
</Field>
<Field>
<Label>Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
</Field>
<Field>
<Label>Amount (Rs.)</Label>
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => void handleSave()} disabled={saving || !category || !amount}>
{saving && <Spinner className="size-4" />}
Add entry
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface ProfitLossViewProps {
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function ProfitLossView({ demo = false }: ProfitLossViewProps) {
const [from, setFrom] = React.useState(startOfMonth())
const [to, setTo] = React.useState(new Date())
const [data, setData] = React.useState<ProfitLoss | null>(null)
const [loading, setLoading] = React.useState(true)
const [addOpen, setAddOpen] = React.useState(false)
const [demoEntries, setDemoEntries] = React.useState<LedgerEntry[]>(DEMO_PROFIT_LOSS.entries)
const demoData = React.useMemo<ProfitLoss | null>(() => {
if (!demo) return null
const fromIso = from.toISOString().slice(0, 10)
const toIso = to.toISOString().slice(0, 10)
const inRange = demoEntries.filter((e) => e.entryDate >= fromIso && e.entryDate <= toIso)
return summarizeProfitLossEntries(inRange, fromIso, toIso)
}, [demo, demoEntries, from, to])
const effectiveData = demo ? demoData : data
const effectiveLoading = demo ? false : loading
const load = React.useCallback(async () => {
if (demo) return
setLoading(true)
try {
setData(await getProfitLoss(from, to))
} catch (error) {
toast.error(error instanceof Error ? error.message : "Couldn't load profit & loss.")
} finally {
setLoading(false)
}
}, [from, to, demo])
React.useEffect(() => {
if (demo) return
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch-on-mount/range-change
void load()
}, [load, demo])
async function handleDelete(id: string) {
if (demo) {
setDemoEntries((prev) => prev.filter((e) => e.id !== id))
toast.success("Entry removed.")
return
}
try {
await runMutation(() => deleteLedgerEntry(id))
toast.success("Entry removed.")
void load()
} catch {
// runMutation() already toasted the real error.
}
}
function handleDemoSave(input: AddLedgerEntryInput) {
const entry: LedgerEntry = {
id: crypto.randomUUID(),
entryType: input.entryType,
category: input.category,
description: input.description ?? null,
amount: input.amount,
entryDate: new Date().toISOString().slice(0, 10),
isManual: true,
}
setDemoEntries((prev) => [entry, ...prev])
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap items-end gap-3">
<Field>
<Label>From</Label>
<Input
type="date"
value={from.toISOString().slice(0, 10)}
onChange={(e) => setFrom(new Date(e.target.value))}
/>
</Field>
<Field>
<Label>To</Label>
<Input
type="date"
value={to.toISOString().slice(0, 10)}
onChange={(e) => setTo(new Date(e.target.value))}
/>
</Field>
</div>
<Button onClick={() => setAddOpen(true)}>
<PlusIcon />
Add entry
</Button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
icon={TrendingUpIcon}
tone="green"
label="Income"
value={formatCurrency(effectiveData?.totalIncome ?? 0)}
/>
<StatCard
icon={TrendingDownIcon}
tone="amber"
label="Expenses"
value={formatCurrency(effectiveData?.totalExpense ?? 0)}
/>
<StatCard
icon={WalletIcon}
tone={effectiveData && effectiveData.netProfit < 0 ? "amber" : "blue"}
label="Net profit"
value={formatCurrency(effectiveData?.netProfit ?? 0)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-sm">Income by category</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1.5">
{!effectiveData || effectiveData.incomeByCategory.length === 0 ? (
<p className="text-sm text-muted-foreground">No income recorded.</p>
) : (
effectiveData.incomeByCategory.map((c) => (
<div key={c.category} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{c.category}</span>
<span className="font-medium">{formatCurrency(c.amount)}</span>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Expenses by category</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1.5">
{!effectiveData || effectiveData.expenseByCategory.length === 0 ? (
<p className="text-sm text-muted-foreground">No expenses recorded.</p>
) : (
effectiveData.expenseByCategory.map((c) => (
<div key={c.category} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{c.category}</span>
<span className="font-medium">{formatCurrency(c.amount)}</span>
</div>
))
)}
</CardContent>
</Card>
</div>
<div className="overflow-hidden rounded-lg border bg-card text-card-foreground">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Category</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{effectiveLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
<Spinner className="mx-auto size-5" />
</TableCell>
</TableRow>
) : !effectiveData || effectiveData.entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
No ledger entries for this period.
</TableCell>
</TableRow>
) : (
effectiveData.entries.map((entry, i) => (
<TableRow key={entry.id ?? `derived-${i}`}>
<TableCell>{formatDate(entry.entryDate)}</TableCell>
<TableCell>
<Badge variant={entry.entryType === "Income" ? "secondary" : "outline"}>{entry.entryType}</Badge>
</TableCell>
<TableCell>{entry.category}</TableCell>
<TableCell className="text-muted-foreground">{entry.description ?? "—"}</TableCell>
<TableCell className="text-right font-medium">{formatCurrency(entry.amount)}</TableCell>
<TableCell className="text-right">
{entry.isManual && entry.id ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete entry"
onClick={() => void handleDelete(entry.id!)}
>
<Trash2Icon />
</Button>
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddEntryDialog
open={addOpen}
onOpenChange={setAddOpen}
onSaved={() => void load()}
demo={demo}
onDemoSave={handleDemoSave}
/>
</div>
)
}
export { ProfitLossView }
@@ -0,0 +1,440 @@
"use client"
import * as React from "react"
import html2canvas from "html2canvas-pro"
import { jsPDF } from "jspdf"
import { DownloadIcon, SendIcon } from "lucide-react"
import { toast } from "sonner"
import { getMemberInvoice, sendReceipt } from "@/lib/api/invoices"
import { getSubscription, getSubscriptionPayments } from "@/lib/api/subscriptions"
import { formatCurrency } from "@/lib/format"
import { useMediaQuery } from "@/lib/use-media-query"
import { runMutation } from "@/lib/use-mutation"
import { DEMO_SUBSCRIPTION_PAYMENTS } from "@/lib/demo/mock-data"
import type { MemberInvoice } from "@/lib/models/invoice"
import type { SubscriptionPayment, SubscriptionPaymentsResult, SubscriptionSummary } from "@/lib/models/subscription"
import { useClientProfile } from "@/components/hexa/client-profile-context"
import { GymInvoice } from "@/components/hexa/invoice/gym-invoice"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
interface ReceiptPreviewDialogProps {
invoice: MemberInvoice | null
open: boolean
onOpenChange: (open: boolean) => void
/** Sandbox demo mode: look up the subscription from local data instead of calling the backend. */
demo?: boolean
demoSubscriptions?: SubscriptionSummary[]
}
/**
* DD/MM/YYYY — the date format this app's invoice uses.
*/
function formatInvoiceDate(iso: string) {
const d = new Date(iso)
const day = String(d.getDate()).padStart(2, "0")
const month = String(d.getMonth() + 1).padStart(2, "0")
return `${day}/${month}/${d.getFullYear()}`
}
/** How the balance was actually collected — derived from the recorded PaymentTransaction rows. */
function describePaymentMethod(payments: SubscriptionPayment[]): string {
const approved = payments.filter((p) => p.isApproved !== false && p.paymentMethod)
const methods = Array.from(new Set(approved.map((p) => p.paymentMethod)))
if (methods.length === 0) return "—"
if (methods.length > 1) return "Cash + Bank"
if (methods[0] === "Bank") {
const ref = approved.find((p) => p.bankRefNo)?.bankRefNo
return ref ? `Bank (Ref: ${ref})` : "Bank"
}
return "Cash"
}
export function ReceiptPreviewDialog({
invoice,
open,
onOpenChange,
demo = false,
demoSubscriptions,
}: ReceiptPreviewDialogProps) {
const { gymName, address, city, phone, email } = useClientProfile()
const isDesktop = useMediaQuery("(min-width: 768px)")
/**
* This now points to the FULL A4 invoice page.
* The PDF download captures this exact preview as one page.
*/
const bodyRef = React.useRef<HTMLDivElement>(null)
/**
* Kept because GymInvoice still accepts it for compatibility,
* but the PDF no longer captures footer separately.
*/
const footerRef = React.useRef<HTMLDivElement>(null)
const [downloading, setDownloading] = React.useState(false)
const [sending, setSending] = React.useState(false)
const [subscription, setSubscription] =
React.useState<SubscriptionSummary | null>(null)
const [subscriptionPayments, setSubscriptionPayments] =
React.useState<SubscriptionPaymentsResult | null>(null)
const [carriedForwardAmount, setCarriedForwardAmount] = React.useState(0)
const [loadingDetails, setLoadingDetails] = React.useState(false)
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (!open) {
setSubscription(null)
setSubscriptionPayments(null)
setCarriedForwardAmount(0)
}
}
const subscriptionId = invoice?.fkCustomerSubscriptionId
const demoSubscriptionMatch = React.useMemo(() => {
if (!demo || !open || !subscriptionId) return null
return demoSubscriptions?.find((s) => s.subscriptionId === subscriptionId) ?? null
}, [demo, open, subscriptionId, demoSubscriptions])
React.useEffect(() => {
if (!open || !subscriptionId || demo) return
let cancelled = false
const invoiceId = invoice?.customerInvoiceId
async function load(id: string) {
setLoadingDetails(true)
try {
const [sub, payments, invoiceDetail] = await Promise.all([
getSubscription(id),
getSubscriptionPayments(id),
// Only the detail endpoint carries carriedForwardAmount -- the row that opened this
// dialog may have come from a list that doesn't compute it.
invoiceId ? getMemberInvoice(invoiceId) : Promise.resolve(null),
])
if (!cancelled) {
setSubscription(sub)
setSubscriptionPayments(payments)
setCarriedForwardAmount(invoiceDetail?.carriedForwardAmount ?? 0)
}
} catch {
/**
* Best-effort.
* The receipt can still render with invoice-level data.
*/
} finally {
if (!cancelled) setLoadingDetails(false)
}
}
load(subscriptionId)
return () => {
cancelled = true
}
}, [open, subscriptionId, demo, invoice?.customerInvoiceId])
const effectiveSubscription = demo ? demoSubscriptionMatch : subscription
const effectivePayments: SubscriptionPayment[] = demo
? (subscriptionId && DEMO_SUBSCRIPTION_PAYMENTS[subscriptionId]) || []
: subscriptionPayments && invoice && subscriptionPayments.invoiceId === invoice.customerInvoiceId
? subscriptionPayments.payments
: []
if (!invoice) return null
const memberSub = invoice.customerSubscription
const member = memberSub?.customer
const plan = memberSub?.customerPlan
const memberName = member?.cusName ?? "Member"
const planName = plan?.planName ?? "Membership"
const planScope = plan?.planScope ?? "Individual"
const expiryDate = memberSub?.expiryDate
const paid = invoice.paidAmount ?? 0
const balance = invoice.balance ?? invoice.amount - paid
const receiptNo = `HF-${new Date(invoice.customerInvoiceDate).getFullYear()}-${invoice.customerInvoiceId
.replace(/-/g, "")
.slice(0, 8)
.toUpperCase()}`
const memberNames =
planScope === "Group" && effectiveSubscription?.members?.length
? effectiveSubscription.members.map((m) => m.cusName ?? "Member").join(", ")
: memberName
const allowSplit = plan?.allowSplitPayments ?? false
const installments = effectiveSubscription?.installments ?? []
const showSplitRows = allowSplit && installments.length > 1
const awaitingSplitDetails = allowSplit && loadingDetails
// Renewal-approval carry-forward: part of `invoice.amount` is this cycle's own charge, part is
// unpaid debt rolled over from a prior period (see SubscriptionRenewalRunner). Show both instead
// of a flat total so the member can see how the figure was built. Installment rows (if any)
// already itemize the this-cycle portion, so only add a standalone "this cycle" row when there
// are no installment rows to do that job -- otherwise the amount would be listed twice.
const thisCycleAmount = invoice.amount - carriedForwardAmount
const carryForwardRows =
carriedForwardAmount > 0
? [
...(showSplitRows ? [] : [{ label: planName, amount: thisCycleAmount }]),
{ label: "Previous balance (carried forward)", amount: carriedForwardAmount },
]
: []
/**
* Placeholders for fields the backend does not track yet.
* Replace these when your API starts sending real values.
*/
const country = "Sri Lanka"
const dueDate = invoice.customerInvoiceDate
const paymentMethod = describePaymentMethod(effectivePayments)
const preparedBy = "Admin Officer"
const gymAddress = address ?? "25 Wellness Avenue"
const gymCity = city ?? "Colombo 05"
const gymPhone = phone ?? "+94 11 234 5678"
const gymEmail = email ?? "hello@hexafitness.com"
const memberAddress = member?.address ?? "18 Lake Drive"
const memberCity = "Colombo 07"
const memberPhone = member?.primaryPH ?? "+94 77 123 4567"
const memberEmail = member?.email ?? "member@email.com"
const website = "www.hexafitness.com"
async function handleDownload() {
if (!bodyRef.current) return
const invoiceElement = bodyRef.current
setDownloading(true)
try {
await runMutation(async () => {
await document.fonts?.ready
/**
* Capture the full fixed A4 invoice preview.
* No slicing. No footer stamping. No multi-page split.
*/
const canvas = await html2canvas(invoiceElement, {
scale: 3,
backgroundColor: "#ffffff",
useCORS: true,
allowTaint: true,
logging: false,
windowWidth: invoiceElement.scrollWidth,
windowHeight: invoiceElement.scrollHeight,
})
const imgData = canvas.toDataURL("image/png")
const pdf = new jsPDF({
unit: "pt",
format: "a4",
orientation: "portrait",
compress: true,
})
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
/**
* GymInvoice is fixed to A4 ratio: 794 × 1123.
* This renders the exact preview into one A4 page.
*/
pdf.addImage(imgData, "PNG", 0, 0, pageWidth, pageHeight)
pdf.save(`${receiptNo}.pdf`)
})
} catch {
// runMutation() already toasted the real error.
} finally {
setDownloading(false)
}
}
async function handleSend() {
if (!invoice) return
if (demo) {
toast.success("Receipt sent to the member")
return
}
setSending(true)
try {
await runMutation(() => sendReceipt(invoice.customerInvoiceId))
toast.success("Receipt sent to the member")
} catch {
// runMutation() already toasted the real error.
} finally {
setSending(false)
}
}
const receipt = awaitingSplitDetails ? (
<div className="mx-auto flex w-198.5 flex-col gap-3 bg-white p-14">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<GymInvoice
bodyRef={bodyRef}
footerRef={footerRef}
invoiceNo={receiptNo}
invoiceDate={formatInvoiceDate(invoice.customerInvoiceDate)}
dueDate={formatInvoiceDate(dueDate)}
logoSrc="/images/hexa_Logo.png"
logoAlt={gymName ?? "Gym"}
from={{
name: gymName ?? "Your Gym",
address: [gymAddress, gymCity, country],
phone: gymPhone,
email: gymEmail,
}}
billTo={{
name: memberName,
address: [memberAddress, memberCity, country],
phone: memberPhone,
email: memberEmail,
}}
plan={{
gymPlanName: planName,
type: planScope,
memberName: memberNames,
validityPeriod: expiryDate
? `Valid until ${formatInvoiceDate(expiryDate)}`
: "Ongoing",
}}
payment={{
subtotal: invoice.amount,
payments: [
...(showSplitRows
? installments.map((inst) => ({
label: `Payment ${String(inst.installmentNo).padStart(2, "0")}`,
amount: inst.amount,
}))
: []),
...carryForwardRows,
],
totalPaid: paid,
balanceDue: balance,
}}
paymentMethod={paymentMethod}
preparedBy={preparedBy}
notes={[
"Payment received for gym membership.",
"Membership is non-transferable and valid until expiry date.",
]}
footerItems={[website, gymEmail, gymPhone]}
formatAmount={formatCurrency}
/>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Receipt</DialogTitle>
<DialogDescription>
Preview, download or send this receipt.
</DialogDescription>
</DialogHeader>
<div className="w-full max-h-[70vh] overflow-x-hidden bg-[#f4f4f4] px-4 py-6">
{receipt}
</div>
<p className="mb-4 text-xs text-muted-foreground">
&quot;Download PDF&quot; generates this exact preview.
&quot;Send to member&quot; delivers the receipt by email/SMS in the
gym&apos;s on-file format.
</p>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button variant="outline" onClick={handleSend} disabled={sending}>
{sending ? <Spinner className="size-4" /> : <SendIcon />}
Send to member
</Button>
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? <Spinner className="size-4" /> : <DownloadIcon />}
Download PDF
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>Receipt</SheetTitle>
<SheetDescription>
Preview, download or send this receipt.
</SheetDescription>
</SheetHeader>
<div className="-mx-6 max-h-[70vh] overflow-x-hidden bg-[#f4f4f4] px-4 py-6">
{receipt}
</div>
<p className="px-4 text-xs text-muted-foreground">
&quot;Download PDF&quot; generates this exact preview.
&quot;Send to member&quot; delivers the receipt by email/SMS in the
gym&apos;s on-file format.
</p>
<SheetFooter className="flex-col-reverse">
<Button
variant="outline"
className="w-full"
onClick={handleSend}
disabled={sending}
>
{sending ? <Spinner className="size-4" /> : <SendIcon />}
Send to member
</Button>
<Button
className="w-full"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? <Spinner className="size-4" /> : <DownloadIcon />}
Download PDF
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,205 @@
"use client"
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { useMediaQuery } from "@/lib/use-media-query"
import { useMutation } from "@/lib/use-mutation"
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
interface RecordPaymentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (amount: number, note: string, paymentMethod: "Cash" | "Bank", bankRefNo: string) => Promise<void>
/** Outstanding balance — the maximum payable. */
balance: number
/** When false the amount is locked to the full balance (full payment only). */
allowSplit: boolean
/** Prefill (next unpaid installment amount); falls back to the balance. */
suggestedAmount?: number
}
function RecordPaymentDialog({
open,
onOpenChange,
onSubmit,
balance,
allowSplit,
suggestedAmount,
}: RecordPaymentDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [amount, setAmount] = React.useState("")
const [note, setNote] = React.useState("")
const [paymentMethod, setPaymentMethod] = React.useState<"Cash" | "Bank">("Cash")
const [bankRefNo, setBankRefNo] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
const prefill = allowSplit ? (suggestedAmount ?? balance) : balance
setAmount(String(prefill))
setNote("")
setPaymentMethod("Cash")
setBankRefNo("")
}
}
const numeric = Number(amount)
const valid = numeric > 0 && numeric <= balance
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid || busy) return
try {
await run(() => onSubmit(numeric, note.trim(), paymentMethod, bankRefNo.trim()))
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open so the user can retry.
}
}
const fields = (
<>
<div className="flex flex-col gap-1.5">
<Label htmlFor="pay-amount">
Amount (LKR) <span className="text-destructive">*</span>
</Label>
<Input
id="pay-amount"
type="number"
min={0}
max={balance}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
{numeric > balance && (
<span className="text-xs text-destructive">Amount exceeds the balance.</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>
Paid via <span className="text-destructive">*</span>
</Label>
<RadioGroup
value={paymentMethod}
onValueChange={(v) => setPaymentMethod((v as "Cash" | "Bank") ?? "Cash")}
className="grid-cols-2"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="Cash" /> Cash
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="Bank" /> Bank
</label>
</RadioGroup>
</div>
{paymentMethod === "Bank" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="pay-bank-ref">Bank reference / slip no.</Label>
<Input
id="pay-bank-ref"
value={bankRefNo}
onChange={(e) => setBankRefNo(e.target.value)}
placeholder="e.g. SLIP-00214"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="pay-note">Note</Label>
<Textarea
id="pay-note"
placeholder="Optional note"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
</>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Record payment</DialogTitle>
<DialogDescription>
Outstanding balance: {formatCurrency(balance)}
{!allowSplit && " — this plan must be paid in full."}
</DialogDescription>
</DialogHeader>
{fields}
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!valid || busy}>
{busy && <Spinner className="size-4" />}
Record {formatCurrency(numeric || 0)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<SheetHeader>
<SheetTitle>Record payment</SheetTitle>
<SheetDescription>
Outstanding balance: {formatCurrency(balance)}
{!allowSplit && " — this plan must be paid in full."}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4">{fields}</div>
<SheetFooter className="flex-col-reverse">
<Button type="button" variant="outline" className="w-full" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" className="w-full" disabled={!valid || busy}>
{busy && <Spinner className="size-4" />}
Record {formatCurrency(numeric || 0)}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
export { RecordPaymentDialog }
@@ -0,0 +1,331 @@
"use client"
import * as React from "react"
import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { approveRenewal, changeRenewalPlan, getUnapprovedInvoices, rejectRenewal } from "@/lib/api/invoices"
import { getGymPlans } from "@/lib/api/gym-plans"
import { formatCurrency, formatDate } from "@/lib/format"
import { sortByNewest } from "@/lib/validation"
import type { MemberInvoice } from "@/lib/models/invoice"
import { useFetch } from "@/lib/use-fetch"
import { runMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { DetailPanel } from "@/components/hexa/detail-panel"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
interface RenewalApprovalsViewProps {
/** Sandbox demo mode: no unapproved-invoice concept in the demo data yet, so this stays empty. */
demo?: boolean
/** Called after a successful approve/change-plan/reject -- lets the parent refresh the regular
* Invoices tab's separately-fetched list, which these actions change the result of (scope=latest
* resolves to whichever invoice just left/entered the approval queue). */
onActionComplete?: () => void
}
function RenewalApprovalsView({ demo = false, onActionComplete }: RenewalApprovalsViewProps) {
const invoices = useFetch(demo ? async () => [] as MemberInvoice[] : getUnapprovedInvoices)
const plans = useFetch(demo ? async () => [] : () => getGymPlans(false))
const [busyId, setBusyId] = React.useState<string | null>(null)
const [changePlanTarget, setChangePlanTarget] = React.useState<MemberInvoice | null>(null)
const [selectedNewPlanId, setSelectedNewPlanId] = React.useState<string | null>(null)
const [changingPlan, setChangingPlan] = React.useState(false)
const [rejectTarget, setRejectTarget] = React.useState<MemberInvoice | null>(null)
const [rejecting, setRejecting] = React.useState(false)
const [selected, setSelected] = React.useState<MemberInvoice | null>(null)
const rows = sortByNewest(invoices.data ?? [], (r) => r.customerInvoiceDate)
// Change Membership only supports Individual-scope plans (see
// SubscriptionRenewalApprovalService.ChangePlanAsync) -- filter to what would actually succeed.
const individualPlans = (plans.data ?? []).filter((p) => p.isActive && p.planScope === "Individual")
async function handleApprove(row: MemberInvoice) {
if (busyId) return
setBusyId(row.customerInvoiceId)
try {
await runMutation(() => approveRenewal(row.customerInvoiceId))
toastAndReload("Renewal approved.")
} catch {
// runMutation() already toasted the real error.
} finally {
setBusyId(null)
}
}
function toastAndReload(message: string) {
invoices.reload()
onActionComplete?.()
toast.success(message)
}
function openChangePlan(row: MemberInvoice) {
setChangePlanTarget(row)
setSelectedNewPlanId(null)
}
async function handleConfirmChangePlan() {
if (!changePlanTarget || !selectedNewPlanId || changingPlan) return
setChangingPlan(true)
try {
await runMutation(() => changeRenewalPlan(changePlanTarget.customerInvoiceId, selectedNewPlanId))
setChangePlanTarget(null)
toastAndReload("Plan changed and renewal approved.")
} catch {
// runMutation() already toasted the real error.
} finally {
setChangingPlan(false)
}
}
async function handleConfirmReject() {
if (!rejectTarget || rejecting) return
setRejecting(true)
try {
await runMutation(() => rejectRenewal(rejectTarget.customerInvoiceId))
setRejectTarget(null)
toastAndReload("Renewal rejected.")
} catch {
// runMutation() already toasted the real error.
} finally {
setRejecting(false)
}
}
const columns: DataTableColumn<MemberInvoice>[] = [
{
key: "member",
header: "Member",
cell: (row) => (
<div className="leading-tight">
<p className="font-medium">
{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.customerPlan?.planName ?? "—"}
</p>
</div>
),
},
{
key: "date",
header: "Renewal Date",
cell: (row) => <span className="text-muted-foreground">{formatDate(row.customerInvoiceDate)}</span>,
},
{
key: "amount",
header: "Amount Due",
cell: (row) => <span className="font-medium">{formatCurrency(row.amount)}</span>,
},
{
key: "actions",
header: "Actions",
className: "text-right",
cell: (row) => (
<div className="flex items-center justify-end gap-1.5">
<Button
variant="outline"
size="sm"
disabled={busyId === row.customerInvoiceId}
onClick={(e) => {
e.stopPropagation()
void handleApprove(row)
}}
>
{busyId === row.customerInvoiceId ? <Spinner className="size-3.5" /> : <CheckIcon className="size-3.5" />}
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
openChangePlan(row)
}}
>
<RefreshCwIcon className="size-3.5" />
Change Plan
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
setRejectTarget(row)
}}
>
<XIcon className="size-3.5" />
Reject
</Button>
</div>
),
},
]
if (!demo && invoices.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<DataTable
columns={columns}
rows={rows}
rowKey={(row) => row.customerInvoiceId}
onRowClick={(row) => setSelected(row)}
emptyMessage="No renewals awaiting approval."
renderMobileCard={(row) => (
<div className="w-full text-left" onClick={() => setSelected(row)}>
<div className="flex items-center justify-between gap-2">
<div className="leading-tight">
<p className="font-medium">
{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.customerPlan?.planName ?? "—"}
</p>
</div>
<span className="font-semibold">{formatCurrency(row.amount)}</span>
</div>
<div className="mt-2 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<Button
variant="outline"
size="sm"
disabled={busyId === row.customerInvoiceId}
onClick={() => void handleApprove(row)}
>
{busyId === row.customerInvoiceId ? <Spinner className="size-3.5" /> : <CheckIcon className="size-3.5" />}
Approve
</Button>
<Button variant="outline" size="sm" onClick={() => openChangePlan(row)}>
<RefreshCwIcon className="size-3.5" />
Change Plan
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setRejectTarget(row)}
>
<XIcon className="size-3.5" />
Reject
</Button>
</div>
</div>
)}
/>
<Dialog open={!!changePlanTarget} onOpenChange={(open) => !open && setChangePlanTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change membership plan</DialogTitle>
<DialogDescription>
Pick the new plan for{" "}
{changePlanTarget?.customerName ?? changePlanTarget?.customerSubscription?.customer?.cusName ?? "this member"}
. The renewal invoice will be recalculated for the new plan and approved.
</DialogDescription>
</DialogHeader>
<Select
items={individualPlans.map((p) => ({ label: `${p.planName} — ${formatCurrency(p.planPrice)}`, value: p.customerPlanId }))}
value={selectedNewPlanId}
onValueChange={(v) => setSelectedNewPlanId(v ?? null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={individualPlans.length ? "Select a plan" : "No individual plans available"} />
</SelectTrigger>
<SelectContent>
{individualPlans.map((p) => (
<SelectItem key={p.customerPlanId} value={p.customerPlanId}>
{p.planName} — {formatCurrency(p.planPrice)}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setChangePlanTarget(null)}>
Cancel
</Button>
<Button disabled={!selectedNewPlanId || changingPlan} onClick={() => void handleConfirmChangePlan()}>
{changingPlan && <Spinner className="size-3.5" />}
Change plan &amp; approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!rejectTarget} onOpenChange={(open) => !open && setRejectTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject this renewal?</AlertDialogTitle>
<AlertDialogDescription>
{rejectTarget?.customerName ?? rejectTarget?.customerSubscription?.customer?.cusName ?? "This member"}
&rsquo;s subscription will be deactivated and this invoice cancelled. This can&rsquo;t be undone from
here.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={rejecting} onClick={() => void handleConfirmReject()}>
{rejecting && <Spinner className="size-3.5" />}
Reject
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DetailPanel
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
title="Renewal"
description={selected ? formatDate(selected.customerInvoiceDate) : undefined}
>
{selected && (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 py-1 text-sm">
<dt className="text-muted-foreground">Member</dt>
<dd>{selected.customerName ?? selected.customerSubscription?.customer?.cusName ?? "—"}</dd>
<dt className="text-muted-foreground">Plan</dt>
<dd>{selected.customerSubscription?.customerPlan?.planName ?? "—"}</dd>
<dt className="text-muted-foreground">This cycle</dt>
<dd>{formatCurrency(selected.amount - (selected.carriedForwardAmount ?? 0))}</dd>
{!!selected.carriedForwardAmount && (
<>
<dt className="text-muted-foreground">Previous balance</dt>
<dd>{formatCurrency(selected.carriedForwardAmount)}</dd>
</>
)}
<dt className="text-muted-foreground">Total amount due</dt>
<dd className="font-semibold">{formatCurrency(selected.amount)}</dd>
<dt className="text-muted-foreground">Status</dt>
<dd>{selected.status}</dd>
</dl>
)}
</DetailPanel>
</div>
)
}
export { RenewalApprovalsView }
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { BanknoteIcon, TrendingUpIcon, UsersIcon } from "lucide-react"
import { getAttendanceCounts } from "@/lib/api/attendance"
import { getMemberInvoices } from "@/lib/api/invoices"
import { getMonthlyRevenue, getStatSummary } from "@/lib/api/stats"
import { formatCurrency } from "@/lib/format"
import {
DEMO_ATTENDANCE_COUNTS,
DEMO_REVENUE,
DEMO_STAT_SUMMARY,
DEMO_SUBSCRIPTIONS,
} from "@/lib/demo/mock-data"
import { useFetch } from "@/lib/use-fetch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AttendanceChartCard } from "@/components/hexa/attendance-chart-card"
import { PageHeader } from "@/components/hexa/page-header"
import { RevenueChartCard } from "@/components/hexa/revenue-chart-card"
import { StatCard } from "@/components/hexa/stat-card"
import { ProfitLossView } from "@/components/hexa/views/profit-loss-view"
interface ReportsViewProps {
/** Sandbox demo mode: local in-memory data, no API calls. */
demo?: boolean
}
function ReportsView({ demo = false }: ReportsViewProps) {
const summary = useFetch(demo ? async () => DEMO_STAT_SUMMARY : getStatSummary)
const revenue = useFetch(demo ? async () => DEMO_REVENUE : getMonthlyRevenue)
const counts = useFetch(demo ? async () => DEMO_ATTENDANCE_COUNTS : () => getAttendanceCounts())
const invoices = useFetch(demo ? async () => [] : getMemberInvoices)
const paidTotal = demo
? DEMO_SUBSCRIPTIONS.filter((s) => s.paymentStatus === "Paid").reduce((sum, s) => sum + s.paidAmount, 0)
: (invoices.data ?? [])
.filter((i) => i.status.toLowerCase() === "paid")
.reduce((sum, i) => sum + i.amount, 0)
const pendingTotal = demo
? DEMO_SUBSCRIPTIONS.filter((s) => s.paymentStatus !== "Paid").reduce((sum, s) => sum + s.balance, 0)
: (invoices.data ?? [])
.filter((i) => i.status.toLowerCase() === "pending")
.reduce((sum, i) => sum + i.amount, 0)
const growthHint = (pct: number) => `${pct >= 0 ? "+" : ""}${pct.toFixed(1)}% vs last month`
return (
<div className="flex flex-col gap-4">
<PageHeader title="Reports" description="Revenue, attendance, and member trends" />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
icon={BanknoteIcon}
tone="green"
label="Total Revenue"
value={formatCurrency(summary.data?.totalRevenue ?? 0)}
hint="All time"
/>
<StatCard
icon={TrendingUpIcon}
tone="blue"
label="This Month"
value={formatCurrency(summary.data?.thisMonthRevenue ?? 0)}
hint={growthHint(summary.data?.revenueGrowthPercentage ?? 0)}
/>
<StatCard
icon={UsersIcon}
label="Total Members"
value={summary.data?.totalCustomerCount ?? 0}
hint={growthHint(summary.data?.customerGrowthPercentage ?? 0)}
/>
<StatCard
icon={BanknoteIcon}
tone="amber"
label="Outstanding"
value={formatCurrency(pendingTotal)}
hint={`${formatCurrency(paidTotal)} collected`}
/>
</div>
<Tabs defaultValue="revenue">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="revenue" className="flex-1 sm:flex-none">Revenue</TabsTrigger>
<TabsTrigger value="attendance" className="flex-1 sm:flex-none">Attendance</TabsTrigger>
<TabsTrigger value="members" className="flex-1 sm:flex-none">Members</TabsTrigger>
<TabsTrigger value="profit-loss" className="flex-1 sm:flex-none">Profit &amp; Loss</TabsTrigger>
</TabsList>
<TabsContent value="revenue" className="mt-3">
<RevenueChartCard data={revenue.data} loading={demo ? false : revenue.loading} title="Monthly revenue (12 months)" />
</TabsContent>
<TabsContent value="attendance" className="mt-3">
<AttendanceChartCard
data={counts.data}
loading={demo ? false : counts.loading}
title="Most active members (this month)"
limit={10}
/>
</TabsContent>
<TabsContent value="members" className="mt-3">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
icon={UsersIcon}
label="Total"
value={summary.data?.totalCustomerCount ?? 0}
hint="All members"
/>
<StatCard
icon={UsersIcon}
tone="blue"
label="This Month"
value={summary.data?.thisMonthCustomerCount ?? 0}
hint="New joiners"
/>
<StatCard
icon={UsersIcon}
tone="amber"
label="Last Month"
value={summary.data?.lastMonthCustomerCount ?? 0}
hint="Previous period"
/>
<StatCard
icon={TrendingUpIcon}
tone="green"
label="Growth"
value={`${(summary.data?.customerGrowthPercentage ?? 0).toFixed(1)}%`}
hint="Month over month"
/>
</div>
</TabsContent>
<TabsContent value="profit-loss" className="mt-3">
<ProfitLossView demo={demo} />
</TabsContent>
</Tabs>
</div>
)
}
export { ReportsView }
@@ -0,0 +1,347 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { PlusIcon, ShieldIcon, Trash2Icon, UsersRoundIcon } from "lucide-react"
import { getStaff } from "@/lib/api/staff"
import {
assignStaffRole,
createGymRole,
deleteGymRole,
getGymRoles,
setRolePermissions,
} from "@/lib/api/gym-roles"
import type { GymRole } from "@/lib/models/gym-role"
import type { StaffMember } from "@/lib/models/staff"
import { NAV_ITEMS } from "@/lib/nav"
import { useFetch } from "@/lib/use-fetch"
import { runMutation } from "@/lib/use-mutation"
import { useNavItems } from "@/hooks/use-nav-items"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
// "dashboard" is always visible to every staff member and "settings" is owner-only —
// neither is a meaningful permission to grant/deny, so they're excluded from the checklist.
const NOT_ASSIGNABLE = new Set(["dashboard", "settings"])
const DEMO_ROLES: GymRole[] = [
{
gymRoleId: "demo-front-desk",
name: "Front Desk",
isActive: true,
navKeys: ["attendance", "members", "memberships", "customer-payments"],
},
{
gymRoleId: "demo-trainer",
name: "Trainer",
isActive: true,
navKeys: ["attendance", "members", "workout-plans", "meal-plans", "feed"],
},
]
const DEMO_ROLE_STAFF: StaffMember[] = [
{
staffId: "demo-staff-1",
userId: "demo-user-1",
userName: "Amaya Rodrigo",
email: "amaya@pulsefitness.lk",
phoneNumber: "077 201 4455",
clientId: "demo",
clientName: "Pulse Fitness Colombo",
gymRoleId: "demo-front-desk",
gymRoleName: "Front Desk",
staffPosition: "Receptionist",
department: "Operations",
hireDate: "2025-11-04",
isActive: true,
createdAt: "2025-11-04",
},
{
staffId: "demo-staff-2",
userId: "demo-user-2",
userName: "Ravindu Senanayake",
email: "ravindu@pulsefitness.lk",
phoneNumber: "071 488 2210",
clientId: "demo",
clientName: "Pulse Fitness Colombo",
gymRoleId: "demo-trainer",
gymRoleName: "Trainer",
staffPosition: "Senior Trainer",
department: "Coaching",
hireDate: "2025-08-12",
isActive: true,
createdAt: "2025-08-12",
},
]
interface RolesPermissionsViewProps {
/** Sandbox mode keeps roles, permissions, and assignments in local state. */
demo?: boolean
}
function RolesPermissionsView({ demo = false }: RolesPermissionsViewProps) {
const rolesFetch = useFetch(demo ? async () => DEMO_ROLES : getGymRoles)
const staffFetch = useFetch(demo ? async () => DEMO_ROLE_STAFF : getStaff)
const { items: liveNavItems } = useNavItems(!demo)
const navItems = demo ? NAV_ITEMS : liveNavItems
const [newRoleName, setNewRoleName] = React.useState("")
const [creating, setCreating] = React.useState(false)
// Per-row busy tracking: gymRoleId (delete), `${gymRoleId}:${navKey}` (toggle), staffId (assign).
const [busyKeys, setBusyKeys] = React.useState<Set<string>>(new Set())
function withBusy(key: string) {
setBusyKeys((prev) => new Set(prev).add(key))
}
function clearBusy(key: string) {
setBusyKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
const assignablePermissions = navItems.filter((i) => !NOT_ASSIGNABLE.has(i.key))
const roles = rolesFetch.data ?? []
const staff = staffFetch.data ?? []
async function handleCreateRole() {
if (!newRoleName.trim()) {
toast.error("Enter a role name first.")
return
}
setCreating(true)
try {
if (demo) {
const role: GymRole = {
gymRoleId: `demo-role-${roles.length + 1}`,
name: newRoleName.trim(),
isActive: true,
navKeys: [],
}
rolesFetch.mutate((current) => [...(current ?? []), role])
setNewRoleName("")
toast.success("Demo role created")
return
}
await runMutation(() => createGymRole({ name: newRoleName.trim() }))
setNewRoleName("")
toast.success("Role created")
rolesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setCreating(false)
}
}
async function handleDeleteRole(role: GymRole) {
if (demo) {
rolesFetch.mutate((current) =>
current?.filter((item) => item.gymRoleId !== role.gymRoleId) ?? null
)
staffFetch.mutate((current) =>
current?.map((member) =>
member.gymRoleId === role.gymRoleId
? { ...member, gymRoleId: null, gymRoleName: null }
: member
) ?? null
)
toast("Demo role deleted")
return
}
withBusy(role.gymRoleId)
try {
await runMutation(() => deleteGymRole(role.gymRoleId))
toast("Role deleted")
rolesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
clearBusy(role.gymRoleId)
}
}
async function handleTogglePermission(role: GymRole, navKey: string, checked: boolean) {
const nextKeys = checked
? [...role.navKeys, navKey]
: role.navKeys.filter((k) => k !== navKey)
if (demo) {
rolesFetch.mutate((current) =>
current?.map((item) =>
item.gymRoleId === role.gymRoleId ? { ...item, navKeys: nextKeys } : item
) ?? null
)
return
}
const key = `${role.gymRoleId}:${navKey}`
withBusy(key)
try {
await runMutation(() => setRolePermissions(role.gymRoleId, { navKeys: nextKeys }))
rolesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
clearBusy(key)
}
}
async function handleAssignRole(staffId: string, gymRoleId: string | null) {
if (demo) {
const roleName = roles.find((role) => role.gymRoleId === gymRoleId)?.name ?? null
staffFetch.mutate((current) =>
current?.map((member) =>
member.staffId === staffId
? { ...member, gymRoleId, gymRoleName: roleName }
: member
) ?? null
)
toast.success("Demo staff role updated")
return
}
withBusy(staffId)
try {
await runMutation(() => assignStaffRole(staffId, gymRoleId))
toast.success("Staff role updated")
staffFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
clearBusy(staffId)
}
}
if (rolesFetch.loading || staffFetch.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldIcon className="size-4" />
Roles
</CardTitle>
<CardDescription>
Create custom roles and choose which sections of the dashboard each one can access.
New roles start with zero permissions.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex gap-2">
<Input
placeholder="New role name, e.g. Front Desk"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateRole()}
/>
<Button onClick={handleCreateRole} disabled={creating}>
<PlusIcon />
Add role
</Button>
</div>
{roles.length === 0 && (
<p className="text-sm text-muted-foreground">No roles yet. Add one above to get started.</p>
)}
{roles.map((role) => (
<div key={role.gymRoleId} className="rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between">
<p className="font-medium">{role.name}</p>
<Button
variant="destructive"
size="icon-sm"
aria-label="Delete role"
onClick={() => handleDeleteRole(role)}
disabled={busyKeys.has(role.gymRoleId)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{assignablePermissions.map((item) => {
const checked = role.navKeys.includes(item.key)
return (
<label
key={item.key}
className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => handleTogglePermission(role, item.key, v === true)}
disabled={busyKeys.has(`${role.gymRoleId}:${item.key}`)}
/>
{item.label}
</label>
)
})}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersRoundIcon className="size-4" />
Assign staff to a role
</CardTitle>
<CardDescription>
Staff with no role assigned only see the Dashboard until you assign one.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{staff.length === 0 && (
<p className="text-sm text-muted-foreground">No staff members yet.</p>
)}
{staff.map((member) => (
<div
key={member.staffId}
className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{member.userName ?? "Unknown"}</p>
<p className="truncate text-xs text-muted-foreground">{member.staffPosition ?? "—"}</p>
</div>
<div className="w-48 shrink-0">
<Label className="sr-only">Role</Label>
<Select
items={roles.map((r) => ({ value: r.gymRoleId, label: r.name }))}
value={member.gymRoleId ?? null}
onValueChange={(v) => handleAssignRole(member.staffId, v ?? null)}
>
<SelectTrigger className="w-full" disabled={busyKeys.has(member.staffId)}>
<SelectValue placeholder="No role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.gymRoleId} value={r.gymRoleId}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</CardContent>
</Card>
</div>
)
}
export { RolesPermissionsView }

Some files were not shown because too many files have changed in this diff Show More