first commit
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_CORE_API_URL=https://hexafit.hexdive.com
|
||||
NEXT_PUBLIC_AUTH_API_URL=https://hexafit.hexdive.com
|
||||
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.production
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": [
|
||||
"development"
|
||||
],
|
||||
"hints": {
|
||||
"axe/forms": [
|
||||
"default",
|
||||
{
|
||||
"label": "off"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build-time env vars (must be present during `next build` for NEXT_PUBLIC_*)
|
||||
ARG NEXT_PUBLIC_CORE_API_URL
|
||||
ENV NEXT_PUBLIC_CORE_API_URL=${NEXT_PUBLIC_CORE_API_URL}
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
RUN mkdir .next && chown nextjs:nodejs .next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,404 @@
|
||||
# HexaFitness Client Project Reference
|
||||
|
||||
## Overview
|
||||
|
||||
- App name: `hexa-fitness-client`
|
||||
- Stack: Next.js 16 App Router, React 19, TypeScript, Tailwind CSS 4
|
||||
- UI patterns: custom app components plus reusable `src/components/ui/*`
|
||||
- Notifications: `sonner`
|
||||
- Charts: `recharts`
|
||||
- Theme handling: `next-themes`
|
||||
- Main app area: dashboard for gym management
|
||||
- Public flows: landing page, request access, sandbox demo, activation, login
|
||||
|
||||
## Environment
|
||||
|
||||
- Core API base URL: `NEXT_PUBLIC_CORE_API_URL`
|
||||
- Default fallback in code: `http://localhost:5111`
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` - start development server
|
||||
- `npm run build` - production build
|
||||
- `npm run start` - run production server
|
||||
- `npm run lint` - run ESLint
|
||||
|
||||
## App Structure
|
||||
|
||||
### Root App Files
|
||||
|
||||
- `src/app/layout.tsx` - global layout, fonts, theme provider, tooltip provider, toast provider
|
||||
- `src/app/globals.css` - global styling
|
||||
- `src/app/page.tsx` - redirects `/` to `/landing-page`
|
||||
|
||||
### Screens / Routes
|
||||
|
||||
| Route | File | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/` | `src/app/page.tsx` | Root redirect to landing page |
|
||||
| `/landing-page` | `src/app/landing-page/page.tsx` | Marketing website |
|
||||
| `/login` | `src/app/login/page.tsx` | User login form |
|
||||
| `/request-access` | `src/app/request-access/page.tsx` | Mobile OTP onboarding request flow |
|
||||
| `/sandbox/[token]` | `src/app/sandbox/[token]/page.tsx` | Demo dashboard / sandbox experience |
|
||||
| `/activation/[token]` | `src/app/activation/[token]/page.tsx` | Multi-step gym activation flow |
|
||||
| `/dashboard` | `src/app/dashboard/page.tsx` | Dashboard overview |
|
||||
| `/dashboard/attendance` | `src/app/dashboard/attendance/page.tsx` | Attendance tracking |
|
||||
| `/dashboard/members` | `src/app/dashboard/members/page.tsx` | Member listing, details, actions |
|
||||
| `/dashboard/memberships` | `src/app/dashboard/memberships/page.tsx` | Gym plans / memberships |
|
||||
| `/dashboard/payments` | `src/app/dashboard/payments/page.tsx` | Payments and invoices |
|
||||
| `/dashboard/workout-plans` | `src/app/dashboard/workout-plans/page.tsx` | Workout plan management |
|
||||
| `/dashboard/meal-plans` | `src/app/dashboard/meal-plans/page.tsx` | Meal plan management |
|
||||
| `/dashboard/staff` | `src/app/dashboard/staff/page.tsx` | Staff management |
|
||||
| `/dashboard/feed` | `src/app/dashboard/feed/page.tsx` | Feed management |
|
||||
| `/dashboard/tickets` | `src/app/dashboard/tickets/page.tsx` | Support tickets |
|
||||
| `/dashboard/payouts` | `src/app/dashboard/payouts/page.tsx` | Wallet, KYC, payout setup |
|
||||
| `/dashboard/reports` | `src/app/dashboard/reports/page.tsx` | Revenue, attendance, trends |
|
||||
|
||||
### Layout and Loading Files
|
||||
|
||||
- `src/app/dashboard/layout.tsx` - auth gate, wraps dashboard in `DashboardShell`
|
||||
- `src/app/dashboard/loading.tsx` - dashboard loading state
|
||||
- `src/app/dashboard/attendance/loading.tsx`
|
||||
- `src/app/dashboard/feed/loading.tsx`
|
||||
- `src/app/dashboard/members/loading.tsx`
|
||||
- `src/app/dashboard/memberships/loading.tsx`
|
||||
- `src/app/dashboard/meal-plans/loading.tsx`
|
||||
- `src/app/dashboard/payments/loading.tsx`
|
||||
- `src/app/dashboard/payouts/loading.tsx`
|
||||
- `src/app/dashboard/reports/loading.tsx`
|
||||
- `src/app/dashboard/staff/loading.tsx`
|
||||
- `src/app/dashboard/tickets/loading.tsx`
|
||||
- `src/app/dashboard/workout-plans/loading.tsx`
|
||||
|
||||
## Navigation
|
||||
|
||||
- Source: `src/lib/nav.ts`
|
||||
- Main dashboard nav items:
|
||||
- Dashboard
|
||||
- Attendance
|
||||
- Members
|
||||
- Memberships
|
||||
- Payments & Invoices
|
||||
- Workout Plans
|
||||
- Meal Plans
|
||||
- Staff
|
||||
- Feed
|
||||
- Tickets
|
||||
- Payouts & Wallet
|
||||
- Reports
|
||||
- Mobile primary keys: `dashboard`, `members`, `attendance`, `payments`
|
||||
|
||||
## API Layer
|
||||
|
||||
### Core HTTP
|
||||
|
||||
- `src/lib/api/core-http.ts`
|
||||
- Responsibilities:
|
||||
- centralized `apiRequest`
|
||||
- auth-aware fetch with cookies
|
||||
- auto refresh on `401`
|
||||
- redirect to `/login` when session refresh fails
|
||||
- `ApiError` handling
|
||||
- shared helpers: `findField`, `refreshSession`
|
||||
|
||||
### Auth APIs
|
||||
|
||||
- `src/lib/api/auth.ts`
|
||||
- `login(request)` - login user and extract access/refresh token fields
|
||||
- `src/lib/api/session.ts`
|
||||
- `logout()` - clear server cookies and local auth flag
|
||||
- `refreshSession()` - re-exported refresh helper
|
||||
|
||||
### Onboarding / Activation APIs
|
||||
|
||||
- `src/lib/api/onboarding.ts`
|
||||
- `checkMobileNumber(request)` - check if onboarding already exists
|
||||
- `sendMobileOtp(request)` - send request-access OTP
|
||||
- `verifyMobileOtp(request)` - verify request-access OTP
|
||||
- `createOnboardingRequest(requestAccessToken, request)` - submit gym onboarding request
|
||||
- `getSandboxStatus(sandboxToken)` - fetch demo/sandbox status
|
||||
- `updateSandboxProgress(sandboxToken, update)` - track sandbox usage/tour progress
|
||||
- `src/lib/api/activation.ts`
|
||||
- `sendActivationEmailOtp(token, request)` - send activation email OTP
|
||||
- `verifyActivationEmailOtp(token, request)` - verify activation email OTP
|
||||
- `uploadNic(token, nicNumber, frontImage, backImage)` - upload identity documents
|
||||
- `updateGymProfile(token, request)` - save gym profile info
|
||||
- `setPaymentMethod(token, request)` - save activation payment method
|
||||
- `chargeAndActivate(token)` - finalize activation
|
||||
- `getActivationChecklist(token)` - fetch post-activation checklist
|
||||
|
||||
### Dashboard Business APIs
|
||||
|
||||
- `src/lib/api/profile.ts`
|
||||
- `getMyProfile()` - fetch logged-in client profile
|
||||
- `src/lib/api/stats.ts`
|
||||
- `getStatSummary()` - dashboard summary stats
|
||||
- `getMonthlyRevenue()` - revenue chart data
|
||||
- `getRecentPayments()` - recent payment list
|
||||
- `getRecentAttendance()` - recent attendance list
|
||||
- `src/lib/api/attendance.ts`
|
||||
- `getAllAttendance()` - full attendance records
|
||||
- `getTodayAttendance()` - today check-ins
|
||||
- `getAttendanceCounts(fromDate, toDate)` - aggregated attendance counts
|
||||
- `src/lib/api/customers.ts`
|
||||
- `getMembers()` - list members with subscriptions
|
||||
- `getMember(customerId)` - member detail
|
||||
- `createMember(input)` - add member
|
||||
- `updateMember(customerId, input)` - edit member
|
||||
- `deleteMember(customerId)` - remove member
|
||||
- `src/lib/api/gym-plans.ts`
|
||||
- `getGymPlans(includeInactive)` - list gym membership plans
|
||||
- `getGymPlan(id)` - single gym plan
|
||||
- `createGymPlan(input)` - create gym plan
|
||||
- `updateGymPlan(id, input)` - update gym plan
|
||||
- `deleteGymPlan(id, hard)` - delete or soft delete plan
|
||||
- `src/lib/api/subscriptions.ts`
|
||||
- `getSubscription(id)` - subscription summary
|
||||
- `getSubscriptionPayments(id)` - subscription payment history
|
||||
- `recordSubscriptionPayment(id, input)` - add payment to subscription
|
||||
- `addSubscriptionMember(id, customerId)` - attach member to subscription
|
||||
- `removeSubscriptionMember(id, subscriptionMemberId)` - remove member from subscription
|
||||
- `src/lib/api/invoices.ts`
|
||||
- `getMemberInvoices()` - list invoices
|
||||
- `getMemberInvoice(id)` - invoice detail
|
||||
- `src/lib/api/payments-enablement.ts`
|
||||
- `getPaymentTracks()` - available payout/payment tracks
|
||||
- `selectPaymentTrack(request)` - choose a payment track
|
||||
- `submitPayoutKyc(brCertificate, bankProof)` - upload payout KYC docs
|
||||
- `getKycStatus()` - read KYC state
|
||||
- `getWallet()` - wallet summary
|
||||
- `getPayoutSchedule()` - payout schedule data
|
||||
- `src/lib/api/staff.ts`
|
||||
- `getStaff()` - list staff
|
||||
- `getStaffMember(staffId)` - single staff member
|
||||
- `createStaff(input)` - create staff member
|
||||
- `updateStaff(staffId, input)` - update staff
|
||||
- `deleteStaff(staffId, softDelete)` - delete staff member
|
||||
- `src/lib/api/feed.ts`
|
||||
- `getFeed()` - list feed posts
|
||||
- `createFeedPost(input)` - create post
|
||||
- `deleteFeedPost(postId)` - delete post
|
||||
- `likePost(postId)` - like post
|
||||
- `unlikePost(postId)` - unlike post
|
||||
- `isPostLiked(postId)` - fetch current user like state
|
||||
- `src/lib/api/tickets.ts`
|
||||
- `getTickets()` - list support tickets
|
||||
- `getTicket(id)` - ticket detail
|
||||
- `getTicketCategories()` - support categories
|
||||
- `createTicket(mainReason, ticketCategoryId)` - open ticket
|
||||
- `sendTicketMessage(id, message)` - add ticket message
|
||||
- `submitTicketFeedback(id, rating, feedback)` - submit support feedback
|
||||
- `closeTicket(id)` - close ticket
|
||||
- `deleteTicket(id)` - delete ticket
|
||||
|
||||
### Plan APIs
|
||||
|
||||
- `src/lib/api/workout-plans.ts`
|
||||
- `getWorkoutPlans()`
|
||||
- `getWorkoutCategories()`
|
||||
- `createWorkoutCategory(name, description)`
|
||||
- `createWorkoutPlan(input)`
|
||||
- `updateWorkoutPlan(id, input)`
|
||||
- `deleteWorkoutPlan(id)`
|
||||
- `assignWorkoutPlan(id, customerId, notes)`
|
||||
- `getWorkoutItems(planId)`
|
||||
- `createWorkoutItem(planId, title, detail)`
|
||||
- `src/lib/api/meal-plans.ts`
|
||||
- `getMealPlans()`
|
||||
- `getMealCategories()`
|
||||
- `createMealCategory(name, description)`
|
||||
- `createMealPlan(input)`
|
||||
- `updateMealPlan(id, input)`
|
||||
- `deleteMealPlan(id)`
|
||||
- `assignMealPlan(id, customerId, notes)`
|
||||
- `getMealItems(planId)`
|
||||
- `createMealItem(planId, title, detail)`
|
||||
|
||||
## Components
|
||||
|
||||
### Shared App Components
|
||||
|
||||
- `src/components/theme-provider.tsx` - global theme wrapper
|
||||
|
||||
### Marketing Components
|
||||
|
||||
- `src/components/marketing/marketing-header.tsx`
|
||||
- `src/components/marketing/hero-section.tsx`
|
||||
- `src/components/marketing/features-section.tsx`
|
||||
- `src/components/marketing/cta-section.tsx`
|
||||
- `src/components/marketing/marketing-footer.tsx`
|
||||
|
||||
### Onboarding Components
|
||||
|
||||
- `src/components/onboarding/onboarding-layout.tsx` - shared layout for request access and activation flows
|
||||
|
||||
### Dashboard / Hexa Components
|
||||
|
||||
- `src/components/hexa/app-sidebar.tsx`
|
||||
- `src/components/hexa/attendance-chart-card.tsx`
|
||||
- `src/components/hexa/back-button.tsx`
|
||||
- `src/components/hexa/checkin-list.tsx`
|
||||
- `src/components/hexa/client-profile-context.tsx`
|
||||
- `src/components/hexa/dashboard-shell.tsx`
|
||||
- `src/components/hexa/data-table.tsx`
|
||||
- `src/components/hexa/detail-panel.tsx`
|
||||
- `src/components/hexa/filter-bar.tsx`
|
||||
- `src/components/hexa/info-banner.tsx`
|
||||
- `src/components/hexa/mobile-nav.tsx`
|
||||
- `src/components/hexa/page-header.tsx`
|
||||
- `src/components/hexa/page-skeleton.tsx`
|
||||
- `src/components/hexa/pagination-bar.tsx`
|
||||
- `src/components/hexa/post-card.tsx`
|
||||
- `src/components/hexa/refresh-button.tsx`
|
||||
- `src/components/hexa/revenue-chart-card.tsx`
|
||||
- `src/components/hexa/sandbox-tour.tsx`
|
||||
- `src/components/hexa/selectable-card.tsx`
|
||||
- `src/components/hexa/site-header.tsx`
|
||||
- `src/components/hexa/stat-card.tsx`
|
||||
- `src/components/hexa/status-badge.tsx`
|
||||
- `src/components/hexa/theme-toggle.tsx`
|
||||
|
||||
### Dashboard View Components
|
||||
|
||||
- `src/components/hexa/views/feed-view.tsx`
|
||||
- `src/components/hexa/views/gym-plans-view.tsx`
|
||||
- `src/components/hexa/views/payments-enablement-view.tsx`
|
||||
- `src/components/hexa/views/placeholder-view.tsx`
|
||||
- `src/components/hexa/views/plan-detail-panel.tsx`
|
||||
- `src/components/hexa/views/plan-editor-dialog.tsx`
|
||||
- `src/components/hexa/views/plan-management-view.tsx`
|
||||
- `src/components/hexa/views/staff-view.tsx`
|
||||
- `src/components/hexa/views/member-subscription-card.tsx`
|
||||
|
||||
### Dialog / Action Components
|
||||
|
||||
- `src/components/hexa/views/add-category-dialog.tsx`
|
||||
- `src/components/hexa/views/add-group-members-dialog.tsx`
|
||||
- `src/components/hexa/views/add-gym-plan-dialog.tsx`
|
||||
- `src/components/hexa/views/create-feed-dialog.tsx`
|
||||
- `src/components/hexa/views/member-form-dialog.tsx`
|
||||
- `src/components/hexa/views/record-payment-dialog.tsx`
|
||||
- `src/components/hexa/views/staff-form-dialog.tsx`
|
||||
|
||||
### Reusable UI Components
|
||||
|
||||
- `src/components/ui/alert-dialog.tsx`
|
||||
- `src/components/ui/alert.tsx`
|
||||
- `src/components/ui/avatar.tsx`
|
||||
- `src/components/ui/badge.tsx`
|
||||
- `src/components/ui/breadcrumb.tsx`
|
||||
- `src/components/ui/button.tsx`
|
||||
- `src/components/ui/calendar.tsx`
|
||||
- `src/components/ui/card.tsx`
|
||||
- `src/components/ui/chart.tsx`
|
||||
- `src/components/ui/checkbox.tsx`
|
||||
- `src/components/ui/combobox.tsx`
|
||||
- `src/components/ui/command.tsx`
|
||||
- `src/components/ui/dialog.tsx`
|
||||
- `src/components/ui/dropdown-menu.tsx`
|
||||
- `src/components/ui/empty.tsx`
|
||||
- `src/components/ui/field.tsx`
|
||||
- `src/components/ui/input-group.tsx`
|
||||
- `src/components/ui/input-otp.tsx`
|
||||
- `src/components/ui/input.tsx`
|
||||
- `src/components/ui/label.tsx`
|
||||
- `src/components/ui/pagination.tsx`
|
||||
- `src/components/ui/popover.tsx`
|
||||
- `src/components/ui/progress.tsx`
|
||||
- `src/components/ui/radio-group.tsx`
|
||||
- `src/components/ui/scroll-area.tsx`
|
||||
- `src/components/ui/select.tsx`
|
||||
- `src/components/ui/separator.tsx`
|
||||
- `src/components/ui/sheet.tsx`
|
||||
- `src/components/ui/sidebar.tsx`
|
||||
- `src/components/ui/skeleton.tsx`
|
||||
- `src/components/ui/sonner.tsx`
|
||||
- `src/components/ui/spinner.tsx`
|
||||
- `src/components/ui/switch.tsx`
|
||||
- `src/components/ui/table.tsx`
|
||||
- `src/components/ui/tabs.tsx`
|
||||
- `src/components/ui/textarea.tsx`
|
||||
- `src/components/ui/tooltip.tsx`
|
||||
|
||||
## Hooks and Client Utilities
|
||||
|
||||
### Hooks
|
||||
|
||||
- `src/hooks/use-mobile.ts` - mobile breakpoint helper
|
||||
- `src/lib/use-media-query.ts` - media query hook
|
||||
- `src/lib/use-fetch.ts` - lightweight async data fetching helper
|
||||
|
||||
### Utility / Support Files
|
||||
|
||||
- `src/lib/auth/token-service.ts` - local auth/session flag helpers
|
||||
- `src/lib/format.ts` - formatting helpers for currency, dates, etc.
|
||||
- `src/lib/nav.ts` - dashboard navigation config
|
||||
- `src/lib/utils.ts` - shared utility helpers
|
||||
- `src/lib/validation.ts` - validation and masking helpers for onboarding/activation
|
||||
|
||||
## Models / Types
|
||||
|
||||
- `src/lib/models/api.ts` - base API response types
|
||||
- `src/lib/models/auth.ts` - auth request/result types
|
||||
- `src/lib/models/activation.ts` - activation flow types
|
||||
- `src/lib/models/attendance.ts` - attendance-related types
|
||||
- `src/lib/models/customer.ts` - member and customer types
|
||||
- `src/lib/models/feed.ts` - feed post types
|
||||
- `src/lib/models/gym-plan.ts` - gym membership plan types
|
||||
- `src/lib/models/invoice.ts` - invoice types
|
||||
- `src/lib/models/onboarding.ts` - request-access and sandbox types
|
||||
- `src/lib/models/payments-enablement.ts` - wallet, KYC, payout types
|
||||
- `src/lib/models/plan.ts` - reusable workout/meal plan types
|
||||
- `src/lib/models/profile.ts` - client profile types
|
||||
- `src/lib/models/staff.ts` - staff types
|
||||
- `src/lib/models/stats.ts` - dashboard stat types
|
||||
- `src/lib/models/subscription.ts` - subscription and payment types
|
||||
- `src/lib/models/ticket.ts` - support ticket types
|
||||
|
||||
## Public Assets
|
||||
|
||||
### Images
|
||||
|
||||
- `public/images/auth-bg.png`
|
||||
- `public/images/gym-bg.png`
|
||||
- `public/images/landing-hero.png`
|
||||
- `public/images/logo.png`
|
||||
- `public/images/hexa_Logo.png`
|
||||
- `public/images/Hexa_Fitness.png`
|
||||
- `public/images/hexa-Logo-white.png`
|
||||
- `public/images/hexa-Logo-black.png`
|
||||
- `public/images/mobile-bg.png`
|
||||
|
||||
### Other Public Files
|
||||
|
||||
- `public/file.svg`
|
||||
- `public/globe.svg`
|
||||
- `public/next.svg`
|
||||
- `public/vercel.svg`
|
||||
- `public/window.svg`
|
||||
|
||||
## Current Functional Areas
|
||||
|
||||
- Marketing website
|
||||
- Authentication
|
||||
- Request access onboarding
|
||||
- Sandbox demo experience
|
||||
- Gym activation workflow
|
||||
- Dashboard overview analytics
|
||||
- Attendance management
|
||||
- Member management
|
||||
- Membership / gym plan management
|
||||
- Payments and invoices
|
||||
- Workout plan management
|
||||
- Meal plan management
|
||||
- Staff management
|
||||
- Feed / posts
|
||||
- Ticketing / support
|
||||
- Payout and wallet setup
|
||||
- Reports
|
||||
|
||||
## Suggested Use Of This File
|
||||
|
||||
- Use this document as the first-stop project map for onboarding
|
||||
- Update it when new routes, API modules, or components are added
|
||||
- Keep API method names in sync with `src/lib/api/*`
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"admin/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "hexa-fitness-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@microsoft/signalr": "^8.0.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"driver.js": "^1.6.0",
|
||||
"html2canvas-pro": "^2.3.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -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 />
|
||||
}
|
||||
|
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>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
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 }
|
||||