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 }
|
||||||