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