first commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { ClientAccessDevices } from "@/lib/models/access-device"
|
||||
|
||||
function getAccessDevices() {
|
||||
return apiRequest<ClientAccessDevices>("client/access-devices")
|
||||
}
|
||||
|
||||
export interface FacePhotoResult {
|
||||
accessProfileId: number
|
||||
facePhotoStatus: "None" | "Pending" | "Synced" | "Failed"
|
||||
queuedToDevices: number
|
||||
width: number
|
||||
height: number
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a face photo for enrolment into the gym's access terminals. Send a bare base64
|
||||
* JPEG produced by normalizeFacePhoto — the image is never stored server-side, it only
|
||||
* rides the device-command queue until a terminal confirms.
|
||||
*/
|
||||
function setCustomerFacePhoto(customerId: string, imageBase64: string) {
|
||||
return apiRequest<FacePhotoResult>(`client/customers/${customerId}/face-photo`, {
|
||||
method: "POST",
|
||||
body: { imageBase64 },
|
||||
})
|
||||
}
|
||||
|
||||
export { getAccessDevices, setCustomerFacePhoto }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
AttendanceCount,
|
||||
AttendanceRecord,
|
||||
TodayAttendance,
|
||||
} from "@/lib/models/attendance"
|
||||
|
||||
function getAllAttendance() {
|
||||
return apiRequest<AttendanceRecord[]>("client/attendance")
|
||||
}
|
||||
|
||||
function getTodayAttendance() {
|
||||
return apiRequest<TodayAttendance[]>("client/attendance/today")
|
||||
}
|
||||
|
||||
/** Dates as `yyyy-MM-dd`; both optional (defaults to current month on the server). */
|
||||
function getAttendanceCounts(fromDate?: string, toDate?: string) {
|
||||
return apiRequest<AttendanceCount[]>("client/attendance/counts", {
|
||||
query: { fromDate, toDate },
|
||||
})
|
||||
}
|
||||
|
||||
export { getAllAttendance, getTodayAttendance, getAttendanceCounts }
|
||||
@@ -0,0 +1,90 @@
|
||||
import { authRequest, findField } from "@/lib/api/core-http"
|
||||
import { setToken } from "@/lib/auth/token-service"
|
||||
import type { LoginRequest, LoginResult } from "@/lib/models/auth"
|
||||
|
||||
async function login(request: LoginRequest): Promise<LoginResult> {
|
||||
const data = await authRequest<unknown>("loginUser", {
|
||||
method: "POST",
|
||||
body: {
|
||||
functionName: "loginUser",
|
||||
payload: {
|
||||
identifier: request.identifier,
|
||||
password: request.password,
|
||||
deviceName: request.deviceName ?? "WebApp",
|
||||
},
|
||||
reference: `LOGIN${Date.now()}`,
|
||||
},
|
||||
})
|
||||
|
||||
const accessToken = findField(data, ["accessToken", "AccessToken", "token"])
|
||||
const refreshToken = findField(data, ["refreshToken", "RefreshToken"])
|
||||
|
||||
// Persisted here (not left to the caller) so every other api-* module can rely on a
|
||||
// token already being stored the moment login() resolves successfully.
|
||||
if (accessToken) setToken(accessToken)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
/** Step 1 of forgot-password: sends an OTP to the account's email/phone. */
|
||||
async function forgotPassword(identifier: string): Promise<{ referenceNumber: string }> {
|
||||
const data = await authRequest<unknown>("forgotPassword", {
|
||||
method: "POST",
|
||||
body: {
|
||||
functionName: "forgotPassword",
|
||||
payload: { identifier, useResetLink: false, numberOfDigits: 6 },
|
||||
reference: `FORGOT${Date.now()}`,
|
||||
},
|
||||
})
|
||||
const referenceNumber = findField(data, ["referenceNumber", "ReferenceNumber"])
|
||||
if (!referenceNumber) throw new Error("Couldn't start the password reset. Try again.")
|
||||
return { referenceNumber }
|
||||
}
|
||||
|
||||
/** Step 2: verifies the OTP so the account is allowed to reset its password. Goes through
|
||||
* the generic recovery RPC endpoint (auth-service dispatches by functionName), same as
|
||||
* resetPassword/resetPasswordWithToken below. */
|
||||
async function verifyResetOtp(referenceNumber: string, otpCode: string): Promise<void> {
|
||||
await authRequest<unknown>("recovery", {
|
||||
method: "POST",
|
||||
body: {
|
||||
functionName: "VerifyOTP",
|
||||
payload: { referenceNumber, otpCode },
|
||||
reference: `VERIFY${Date.now()}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Step 3: sets the new password (requires the OTP to have been verified). */
|
||||
async function resetPassword(
|
||||
referenceNumber: string,
|
||||
newPassword: string,
|
||||
confirmPassword: string
|
||||
): Promise<void> {
|
||||
await authRequest<unknown>("recovery", {
|
||||
method: "POST",
|
||||
body: {
|
||||
functionName: "resetPassword",
|
||||
payload: { referenceNumber, newPassword, confirmPassword },
|
||||
reference: `RESET${Date.now()}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Sets a new password from the emailed reset/welcome link (?token=...), no OTP involved. */
|
||||
async function resetPasswordWithToken(
|
||||
resetToken: string,
|
||||
newPassword: string,
|
||||
confirmPassword: string
|
||||
): Promise<void> {
|
||||
await authRequest<unknown>("recovery", {
|
||||
method: "POST",
|
||||
body: {
|
||||
functionName: "resetPasswordWithToken",
|
||||
payload: { resetToken, newPassword, confirmPassword },
|
||||
reference: `RESETLINK${Date.now()}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { login, forgotPassword, verifyResetOtp, resetPassword, resetPasswordWithToken }
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { Booking, CreateGuestBookingInput } from "@/lib/models/booking"
|
||||
|
||||
function getSessionBookings(facilitySessionId: string) {
|
||||
return apiRequest<Booking[]>(`client/bookings/session/${facilitySessionId}`)
|
||||
}
|
||||
|
||||
/** Staff-initiated walk-in booking — same public endpoint the guest booking flow uses. */
|
||||
function createGuestBooking(input: CreateGuestBookingInput) {
|
||||
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function checkInBooking(otpCode: string) {
|
||||
return apiRequest<Booking>("client/bookings/check-in", { method: "POST", body: { otpCode } })
|
||||
}
|
||||
|
||||
function markGuestBookingPaid(id: string, amountCharged: number) {
|
||||
return apiRequest<Booking>(`client/bookings/${id}/mark-paid`, {
|
||||
method: "POST",
|
||||
body: { bookingId: id, amountCharged },
|
||||
})
|
||||
}
|
||||
|
||||
function cancelBooking(id: string) {
|
||||
return apiRequest<Booking>(`client/bookings/${id}/cancel`, { method: "POST" })
|
||||
}
|
||||
|
||||
export {
|
||||
getSessionBookings,
|
||||
createGuestBooking,
|
||||
checkInBooking,
|
||||
markGuestBookingPaid,
|
||||
cancelBooking,
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { clearToken, getToken } from "@/lib/auth/token-service"
|
||||
import type { ApiResponse } from "@/lib/models/api"
|
||||
|
||||
// Trimmed so a trailing slash on the env var (e.g. "http://localhost:5601/") can't
|
||||
// produce a double-slash in any URL built below -- some routing layers 404 on that.
|
||||
const CORE_API_URL = (process.env.NEXT_PUBLIC_CORE_API_URL ?? "http://localhost:5601").replace(/\/+$/, "")
|
||||
/** auth-service is a separate process from core-api in this deployment (see AuthHex),
|
||||
* not a path on the same API -- login/logout/forgot-password all go here instead. */
|
||||
const AUTH_API_URL = (process.env.NEXT_PUBLIC_AUTH_API_URL ?? "http://localhost:5602").replace(/\/+$/, "")
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
||||
body?: unknown
|
||||
formData?: FormData
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
/** Public requests (guest booking, the swipe terminal) report 401/errors without
|
||||
* clearing the session or redirecting -- there's no session to clear for them. */
|
||||
authMode?: "session" | "public"
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
if (typeof window === "undefined") return
|
||||
const path = window.location.pathname + window.location.search
|
||||
window.location.href = `/login?next=${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* core-api calls. `path` is relative to `/api/`, e.g. "client/facilities",
|
||||
* "public/bookings/guest". Auth is a `MobileAuthToken: Bearer <token>` header (core-api's
|
||||
* own convention -- see CustomMiddleware on the backend), not a cookie: there is no
|
||||
* server-side session to refresh, so a 401 just clears the stored token and sends the
|
||||
* user back to /login rather than attempting a silent refresh.
|
||||
*/
|
||||
async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, formData, query, authMode = "session" } = options
|
||||
|
||||
const url = new URL(`${CORE_API_URL}/api/${path}`)
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||
const token = getToken()
|
||||
if (token) headers["MobileAuthToken"] = `Bearer ${token}`
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: formData ?? (body !== undefined ? JSON.stringify(body) : undefined),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
"Couldn't reach the server. Check that the API is running and try again.",
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && authMode === "session") {
|
||||
clearToken()
|
||||
redirectToLogin()
|
||||
}
|
||||
throw new ApiError(extractErrorMessage(text, res.status), res.status)
|
||||
}
|
||||
|
||||
if (!text) return undefined as T
|
||||
try {
|
||||
const parsed = JSON.parse(text) as ApiResponse<T>
|
||||
return parsed.data
|
||||
} catch {
|
||||
// A 2xx response with a body that isn't the expected JSON envelope (e.g. an upstream
|
||||
// proxy/error page slipping through with a success status).
|
||||
throw new ApiError("The server returned an unexpected response. Please try again.", res.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** auth-service calls (login/logout/forgot-password) -- separate base URL, no stored
|
||||
* token to attach (these calls establish or end the session, they don't use one). */
|
||||
async function authRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = "POST", body } = options
|
||||
const url = new URL(`${AUTH_API_URL}/api/${path}`)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : {},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
"Couldn't reach the login server. Check that it's running and try again.",
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
if (!res.ok) throw new ApiError(extractErrorMessage(text, res.status), res.status)
|
||||
|
||||
if (!text) return undefined as T
|
||||
try {
|
||||
const parsed = JSON.parse(text) as ApiResponse<T> & { success?: boolean }
|
||||
if (parsed.success === false) throw new ApiError(parsed.message ?? "Request failed.", res.status)
|
||||
return parsed.data
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) throw e
|
||||
throw new ApiError("The login server returned an unexpected response. Please try again.", res.status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a human message from a failed response body — tries the JSON envelope's
|
||||
* `message` first, then falls back to the first line of whatever came back. The backend
|
||||
* has a global exception handler, but this stays defensive in case a proxy/dev-exception
|
||||
* page slips through instead of the JSON envelope.
|
||||
*/
|
||||
function extractErrorMessage(text: string, status: number): string {
|
||||
let message = `Request failed (${status}).`
|
||||
try {
|
||||
const parsed = JSON.parse(text) as ApiResponse<unknown>
|
||||
if (parsed?.message) message = parsed.message
|
||||
} catch {
|
||||
const firstLine = text.split("\n").find((line) => line.trim().length > 0)?.trim()
|
||||
const dotnetExceptionMatch = firstLine?.match(/^[\w.]+Exception:\s*(.+)$/)
|
||||
const cleaned = dotnetExceptionMatch?.[1] ?? firstLine
|
||||
if (cleaned && cleaned.length < 200) message = cleaned
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a binary response (e.g. a generated PDF) as a browser file. Endpoints like
|
||||
* this don't return the `{statusCode, message, data}` JSON envelope on success, so this
|
||||
* can't go through `apiRequest` — but it mirrors the same error-message extraction on
|
||||
* failure, so the real backend error survives instead of a hardcoded generic string.
|
||||
*/
|
||||
async function downloadFile(path: string, filename: string): Promise<void> {
|
||||
const url = new URL(`${CORE_API_URL}/api/${path}`)
|
||||
const token = getToken()
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, { headers: token ? { MobileAuthToken: `Bearer ${token}` } : {} })
|
||||
} catch {
|
||||
throw new ApiError("Couldn't reach the server. Check that the API is running and try again.", 0)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(extractErrorMessage(await res.text(), res.status), res.status)
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = objectUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
/** Digs through a (possibly multiply-wrapped) object for the first matching key. */
|
||||
function findField(obj: unknown, keys: string[], depth = 3): string | null {
|
||||
if (!obj || typeof obj !== "object" || depth < 0) return null
|
||||
const record = obj as Record<string, unknown>
|
||||
for (const key of keys) {
|
||||
if (typeof record[key] === "string") return record[key] as string
|
||||
}
|
||||
if (typeof record.data === "object") return findField(record.data, keys, depth - 1)
|
||||
return null
|
||||
}
|
||||
|
||||
export { apiRequest, authRequest, downloadFile, findField }
|
||||
@@ -0,0 +1,133 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
MedicalDetails,
|
||||
MemberDetail,
|
||||
MemberFullDetail,
|
||||
MemberInput,
|
||||
MemberSubscription,
|
||||
UnlinkedFingerprint,
|
||||
} from "@/lib/models/customer"
|
||||
|
||||
function getMembers() {
|
||||
return apiRequest<MemberSubscription[]>("client/customers")
|
||||
}
|
||||
|
||||
function getMember(customerId: string) {
|
||||
return apiRequest<MemberDetail>(`client/customers/${customerId}`)
|
||||
}
|
||||
|
||||
/** Aggregated profile for the "More Info" view (targets, progress, followed plans). */
|
||||
function getMemberFull(customerId: string) {
|
||||
return apiRequest<MemberFullDetail>(`client/customers/${customerId}/full`)
|
||||
}
|
||||
|
||||
/** Unlocks a member's medical details by re-verifying the owner's password. */
|
||||
function unlockMedical(customerId: string, password: string) {
|
||||
return apiRequest<MedicalDetails>(`client/customers/${customerId}/medical`, {
|
||||
method: "POST",
|
||||
body: { password },
|
||||
})
|
||||
}
|
||||
|
||||
/** Response carries the new subscription id when a plan was assigned (for the group flow). */
|
||||
interface CreateMemberResult {
|
||||
customer: { cusId: string }
|
||||
subscriptionId?: string | null
|
||||
invoiceId?: string | null
|
||||
paymentLinkSent?: boolean
|
||||
paymentLinkError?: string | null
|
||||
/** True when the phone/email already belongs to an existing platform customer and nothing
|
||||
* was written yet -- resubmit with confirmLink: true (after staff confirms) to actually link. */
|
||||
requiresConfirmation?: boolean
|
||||
message?: string
|
||||
existingCustomer?: {
|
||||
cusId: string
|
||||
cusName: string | null
|
||||
primaryPH: string | null
|
||||
email: string | null
|
||||
}
|
||||
}
|
||||
|
||||
function createMember(input: MemberInput) {
|
||||
return apiRequest<CreateMemberResult>("client/customers", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updateMember(customerId: string, input: Omit<MemberInput, "customerPlanId" | "workoutPlanIds" | "mealPlanIds">) {
|
||||
return apiRequest<unknown>(`client/customers/${customerId}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function deleteMember(customerId: string) {
|
||||
return apiRequest<unknown>(`client/customers/${customerId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
/** "Parent" path: starts a brand new subscription for a member who has no plan yet. */
|
||||
interface AssignPlanResult {
|
||||
subscriptionId?: string | null
|
||||
invoiceId?: string | null
|
||||
}
|
||||
|
||||
function assignCustomerPlan(customerId: string, params: {
|
||||
customerPlanId: string
|
||||
expiryDate?: string | null
|
||||
chargePayment?: boolean
|
||||
discountType?: "Percentage" | "Flat"
|
||||
discountValue?: number
|
||||
}) {
|
||||
return apiRequest<AssignPlanResult>(`client/customers/${customerId}/subscription`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
})
|
||||
}
|
||||
|
||||
interface ChangeMembershipPlanResult {
|
||||
subscriptionId?: string | null
|
||||
invoiceId?: string | null
|
||||
invoiceAmount?: number | null
|
||||
/** "Pending" (owes the difference), "Paid" (settled exactly), or "Credit" (overpaid --
|
||||
* negative invoiceAmount, applied automatically to their next bill). */
|
||||
invoiceStatus?: string | null
|
||||
}
|
||||
|
||||
/** Switches a member onto a different Individual-scope plan. */
|
||||
function changeMembershipPlan(customerId: string, customerPlanId: string) {
|
||||
return apiRequest<ChangeMembershipPlanResult>(`client/customers/${customerId}/subscription/plan`, {
|
||||
method: "PUT",
|
||||
body: { customerPlanId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Drops the member's current plan back to unassigned (no new plan is assigned). */
|
||||
function unassignMembership(customerId: string) {
|
||||
return apiRequest<{ success: boolean }>(`client/customers/${customerId}/subscription`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
/** Fingerprints enrolled directly on the device that aren't linked to a Customer yet. */
|
||||
function getUnlinkedFingerprints() {
|
||||
return apiRequest<UnlinkedFingerprint[]>("client/customers/unlinked-fingerprints")
|
||||
}
|
||||
|
||||
function linkFingerprint(customerId: string, unlinkedEnrollmentId: string) {
|
||||
return apiRequest<{ accessProfileId: number; accessNumber: string; name: string | null }>(
|
||||
`client/customers/${customerId}/link-fingerprint`,
|
||||
{ method: "POST", body: { unlinkedEnrollmentId } }
|
||||
)
|
||||
}
|
||||
|
||||
export type { CreateMemberResult, ChangeMembershipPlanResult }
|
||||
|
||||
export {
|
||||
getMembers,
|
||||
getMember,
|
||||
getMemberFull,
|
||||
unlockMedical,
|
||||
createMember,
|
||||
updateMember,
|
||||
deleteMember,
|
||||
assignCustomerPlan,
|
||||
changeMembershipPlan,
|
||||
unassignMembership,
|
||||
getUnlinkedFingerprints,
|
||||
linkFingerprint,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
Facility,
|
||||
FacilityInput,
|
||||
FacilitySession,
|
||||
FacilitySessionInput,
|
||||
} from "@/lib/models/facility"
|
||||
|
||||
function getFacilities(includeInactive = true) {
|
||||
return apiRequest<Facility[]>("client/facilities", { query: { includeInactive } })
|
||||
}
|
||||
|
||||
function createFacility(input: FacilityInput) {
|
||||
return apiRequest<Facility>("client/facilities", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updateFacility(id: string, input: Partial<FacilityInput>) {
|
||||
return apiRequest<Facility>(`client/facilities/${id}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function setFacilityStatus(id: string, isActive: boolean) {
|
||||
return apiRequest<Facility>(`client/facilities/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: { isActive },
|
||||
})
|
||||
}
|
||||
|
||||
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string } = {}) {
|
||||
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
|
||||
}
|
||||
|
||||
function createFacilitySession(input: FacilitySessionInput) {
|
||||
return apiRequest<FacilitySession>("client/facilities/sessions", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function cancelFacilitySession(id: string) {
|
||||
return apiRequest<unknown>(`client/facilities/sessions/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export {
|
||||
getFacilities,
|
||||
createFacility,
|
||||
updateFacility,
|
||||
setFacilityStatus,
|
||||
getFacilitySessions,
|
||||
createFacilitySession,
|
||||
cancelFacilitySession,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiRequest, ApiError } from "@/lib/api/core-http"
|
||||
import type { CreateFeedInput, FeedListResult, FeedPost } from "@/lib/models/feed"
|
||||
|
||||
async function getFeed(): Promise<FeedPost[]> {
|
||||
// ShowFeedByClient double-wraps: ApiResponse.data = { success, message, data: [...] }.
|
||||
// "No feeds found" also reports success:false here, but that's a normal empty state
|
||||
// (not a failure), so this intentionally does not throw on it — just falls back to [].
|
||||
const result = await apiRequest<FeedListResult>("client/feed")
|
||||
return result?.data ?? []
|
||||
}
|
||||
|
||||
async function createFeedPost(input: CreateFeedInput) {
|
||||
// CreateFeed reports real failures (e.g. a rejected document upload) as a 200 response
|
||||
// with success:false instead of throwing — translate that into a real ApiError here so
|
||||
// callers get the same throw-on-failure contract as every other endpoint.
|
||||
const result = await apiRequest<{ success: boolean; message: string }>("client/feed", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
})
|
||||
if (result?.success === false) throw new ApiError(result.message || "Couldn't publish the post.", 200)
|
||||
return result
|
||||
}
|
||||
|
||||
function deleteFeedPost(postId: string) {
|
||||
return apiRequest<unknown>(`client/feed/${postId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
async function likePost(postId: string) {
|
||||
const result = await apiRequest<{ success: boolean; message?: string }>(`client/feed/${postId}/like`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (result?.success === false) throw new ApiError(result.message || "Couldn't like the post.", 200)
|
||||
return result
|
||||
}
|
||||
|
||||
async function unlikePost(postId: string) {
|
||||
const result = await apiRequest<{ success: boolean; message?: string }>(`client/feed/${postId}/like`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (result?.success === false) throw new ApiError(result.message || "Couldn't unlike the post.", 200)
|
||||
return result
|
||||
}
|
||||
|
||||
function isPostLiked(postId: string) {
|
||||
return apiRequest<{ isLiked: boolean }>(`client/feed/${postId}/like`)
|
||||
}
|
||||
|
||||
export { getFeed, createFeedPost, deleteFeedPost, likePost, unlikePost, isPostLiked }
|
||||
@@ -0,0 +1,39 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { GymPlan, GymPlanInput } from "@/lib/models/gym-plan"
|
||||
|
||||
function getGymPlans(includeInactive = true) {
|
||||
return apiRequest<GymPlan[]>("client/plans", { query: { includeInactive } })
|
||||
}
|
||||
|
||||
function getGymPlan(id: string) {
|
||||
return apiRequest<GymPlan>(`client/plans/${id}`)
|
||||
}
|
||||
|
||||
function createGymPlan(input: GymPlanInput) {
|
||||
return apiRequest<GymPlan>("client/plans", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updateGymPlan(id: string, input: GymPlanInput & { isActive?: boolean }) {
|
||||
return apiRequest<GymPlan>(`client/plans/${id}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
/** Activate / deactivate a plan without resending its configuration. */
|
||||
function setGymPlanStatus(id: string, isActive: boolean) {
|
||||
return apiRequest<GymPlan>(`client/plans/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: { isActive },
|
||||
})
|
||||
}
|
||||
|
||||
function deleteGymPlan(id: string, hard = false) {
|
||||
return apiRequest<unknown>(`client/plans/${id}`, { method: "DELETE", query: { hard } })
|
||||
}
|
||||
|
||||
export {
|
||||
getGymPlans,
|
||||
getGymPlan,
|
||||
createGymPlan,
|
||||
updateGymPlan,
|
||||
setGymPlanStatus,
|
||||
deleteGymPlan,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
CreateGymRoleInput,
|
||||
GymRole,
|
||||
SetRolePermissionsInput,
|
||||
UpdateGymRoleInput,
|
||||
} from "@/lib/models/gym-role"
|
||||
|
||||
function getGymRoles() {
|
||||
return apiRequest<GymRole[]>("client/roles")
|
||||
}
|
||||
|
||||
function createGymRole(input: CreateGymRoleInput) {
|
||||
return apiRequest<GymRole>("client/roles", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updateGymRole(gymRoleId: string, input: UpdateGymRoleInput) {
|
||||
return apiRequest<GymRole>(`client/roles/${gymRoleId}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function deleteGymRole(gymRoleId: string) {
|
||||
return apiRequest<unknown>(`client/roles/${gymRoleId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
function setRolePermissions(gymRoleId: string, input: SetRolePermissionsInput) {
|
||||
return apiRequest<GymRole>(`client/roles/${gymRoleId}/permissions`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function assignStaffRole(staffId: string, gymRoleId: string | null) {
|
||||
return apiRequest<unknown>(`client/staff/${staffId}/role`, {
|
||||
method: "PUT",
|
||||
body: { gymRoleId },
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
getGymRoles,
|
||||
createGymRole,
|
||||
updateGymRole,
|
||||
deleteGymRole,
|
||||
setRolePermissions,
|
||||
assignStaffRole,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { apiRequest, downloadFile } from "@/lib/api/core-http"
|
||||
import type { MemberInvoice } from "@/lib/models/invoice"
|
||||
|
||||
// Trimmed so a trailing slash on the env var can't produce a double-slash below.
|
||||
const CORE_API_URL = (process.env.NEXT_PUBLIC_CORE_API_URL ?? "https://hexafit.hexdive.com").replace(/\/+$/, "")
|
||||
|
||||
/** scope="latest" returns one row per customer (their latest approved invoice) instead of
|
||||
* every invoice they've ever had -- used by the regular invoices tab. */
|
||||
function getMemberInvoices(scope?: "latest") {
|
||||
return apiRequest<MemberInvoice[]>("client/member-invoices", { query: { scope } })
|
||||
}
|
||||
|
||||
function getMemberInvoice(id: string) {
|
||||
return apiRequest<MemberInvoice>(`client/member-invoices/${id}`)
|
||||
}
|
||||
|
||||
/** Full invoice history for one customer (previous cycles) -- "view previous invoices". */
|
||||
function getCustomerInvoiceHistory(customerId: string) {
|
||||
return apiRequest<MemberInvoice[]>(`client/member-invoices/customer/${customerId}`)
|
||||
}
|
||||
|
||||
/** The unapproved (IsActive=false) invoices the daily renewal sweep created, awaiting review. */
|
||||
function getUnapprovedInvoices() {
|
||||
return apiRequest<MemberInvoice[]>("client/renewal-approvals")
|
||||
}
|
||||
|
||||
function approveRenewal(invoiceId: string) {
|
||||
return apiRequest<{ success: boolean; message: string; renewalDate?: string }>(
|
||||
`client/renewal-approvals/${invoiceId}/approve`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
function changeRenewalPlan(invoiceId: string, newCustomerPlanId: string) {
|
||||
return apiRequest<{ success: boolean; message: string; subscriptionId: string }>(
|
||||
`client/renewal-approvals/${invoiceId}/change-plan`,
|
||||
{ method: "POST", body: { newCustomerPlanId } }
|
||||
)
|
||||
}
|
||||
|
||||
function rejectRenewal(invoiceId: string) {
|
||||
return apiRequest<{ success: boolean; message: string }>(
|
||||
`client/renewal-approvals/${invoiceId}/reject`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
/** Downloads the branded receipt PDF for an invoice via the browser. */
|
||||
function downloadReceipt(id: string, receiptNo?: string) {
|
||||
return downloadFile(`client/member-invoices/${id}/receipt.pdf`, `${receiptNo ?? `receipt-${id}`}.pdf`)
|
||||
}
|
||||
|
||||
/** Re-sends the receipt to the member via email + SMS. */
|
||||
function sendReceipt(id: string) {
|
||||
return apiRequest<{ success: boolean; message: string }>(
|
||||
`client/member-invoices/${id}/send-receipt`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
/** "Charge now" — attempt to auto-deduct from the member's consented saved card. */
|
||||
function attemptAutoCharge(id: string) {
|
||||
return apiRequest<{ success: boolean; status: string; message: string }>(
|
||||
`client/member-invoices/${id}/auto-charge`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
/** Emails/texts the member a link to pay this invoice online themselves. */
|
||||
function sendPaymentLink(id: string) {
|
||||
return apiRequest<{ success: boolean; message: string }>(
|
||||
`client/member-invoices/${id}/payment-link`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
getMemberInvoices,
|
||||
getMemberInvoice,
|
||||
getCustomerInvoiceHistory,
|
||||
downloadReceipt,
|
||||
sendReceipt,
|
||||
attemptAutoCharge,
|
||||
sendPaymentLink,
|
||||
getUnapprovedInvoices,
|
||||
approveRenewal,
|
||||
changeRenewalPlan,
|
||||
rejectRenewal,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { AddLedgerEntryInput, LedgerEntry, ProfitLoss } from "@/lib/models/ledger"
|
||||
|
||||
function toDateParam(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getProfitLoss(from: Date, to: Date) {
|
||||
return apiRequest<ProfitLoss>("client/reports/profit-loss", {
|
||||
query: { from: toDateParam(from), to: toDateParam(to) },
|
||||
})
|
||||
}
|
||||
|
||||
function getLedgerEntries(from: Date, to: Date) {
|
||||
return apiRequest<LedgerEntry[]>("client/ledger-entries", {
|
||||
query: { from: toDateParam(from), to: toDateParam(to) },
|
||||
})
|
||||
}
|
||||
|
||||
function addLedgerEntry(input: AddLedgerEntryInput) {
|
||||
return apiRequest<LedgerEntry>("client/ledger-entries", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function deleteLedgerEntry(id: string) {
|
||||
return apiRequest<{ deleted: boolean }>(`client/ledger-entries/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export { getProfitLoss, getLedgerEntries, addLedgerEntry, deleteLedgerEntry }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { LockPolicy } from "@/lib/models/lock-policy"
|
||||
import type { AccessCommandStatus } from "@/lib/models/customer"
|
||||
|
||||
function getLockPolicy() {
|
||||
return apiRequest<LockPolicy>("client/settings/lock-policy")
|
||||
}
|
||||
|
||||
function updateLockPolicy(lockAfterDaysOverdue: number | null) {
|
||||
return apiRequest<LockPolicy>("client/settings/lock-policy", {
|
||||
method: "PUT",
|
||||
body: { lockAfterDaysOverdue },
|
||||
})
|
||||
}
|
||||
|
||||
function lockMember(customerId: string) {
|
||||
return apiRequest<AccessCommandStatus>(`client/customers/${customerId}/lock`, { method: "POST" })
|
||||
}
|
||||
|
||||
function unlockMember(customerId: string) {
|
||||
return apiRequest<AccessCommandStatus>(`client/customers/${customerId}/unlock`, { method: "POST" })
|
||||
}
|
||||
|
||||
export { getLockPolicy, updateLockPolicy, lockMember, unlockMember }
|
||||
@@ -0,0 +1,155 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { CreatePlanInput, PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
|
||||
|
||||
interface MealPlanDto {
|
||||
mealPlanId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
isTemplate: boolean
|
||||
mealCategoryId: string
|
||||
mealCategoryName?: string | null
|
||||
clientId?: string | null
|
||||
clientName?: string | null
|
||||
isActive: boolean
|
||||
createDate: string
|
||||
assignedCustomers?: Array<{
|
||||
customerMealId: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
isActive: boolean
|
||||
assignedDate: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface MealCategoryDto {
|
||||
mealCategoryId: string
|
||||
// The backend category DTO exposes the label as `name` (not `mealCategoryName`).
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
function normalizePlan(dto: MealPlanDto): TrainingPlan {
|
||||
return {
|
||||
planId: dto.mealPlanId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
isTemplate: dto.isTemplate,
|
||||
categoryId: dto.mealCategoryId,
|
||||
categoryName: dto.mealCategoryName,
|
||||
clientId: dto.clientId,
|
||||
clientName: dto.clientName,
|
||||
isActive: dto.isActive,
|
||||
createDate: dto.createDate,
|
||||
assignedCustomers: (dto.assignedCustomers ?? []).map((c) => ({
|
||||
assignmentId: c.customerMealId,
|
||||
customerId: c.customerId,
|
||||
customerName: c.customerName,
|
||||
isActive: c.isActive,
|
||||
assignedDate: c.assignedDate,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function getMealPlans(): Promise<TrainingPlan[]> {
|
||||
const dtos = await apiRequest<MealPlanDto[]>("client/meal-plans")
|
||||
return (dtos ?? []).map(normalizePlan)
|
||||
}
|
||||
|
||||
async function getMealCategories(): Promise<PlanCategory[]> {
|
||||
const dtos = await apiRequest<MealCategoryDto[]>("client/meal-plans/categories")
|
||||
return (dtos ?? []).map((c) => ({
|
||||
categoryId: c.mealCategoryId,
|
||||
categoryName: c.name,
|
||||
description: c.description,
|
||||
}))
|
||||
}
|
||||
|
||||
function createMealCategory(name: string, description?: string) {
|
||||
return apiRequest<unknown>("client/meal-plans/categories", {
|
||||
method: "POST",
|
||||
body: { name, description },
|
||||
})
|
||||
}
|
||||
|
||||
function createMealPlan(input: CreatePlanInput & { mealItems?: unknown[] }) {
|
||||
return apiRequest<unknown>("client/meal-plans", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
mealCategoryId: input.categoryId,
|
||||
isTemplate: input.isTemplate,
|
||||
customerIds: input.customerIds,
|
||||
mealItems: input.mealItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateMealPlan(
|
||||
id: string,
|
||||
input: Partial<CreatePlanInput> & { mealItems?: unknown[] }
|
||||
) {
|
||||
return apiRequest<unknown>(`client/meal-plans/${id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
mealCategoryId: input.categoryId,
|
||||
isTemplate: input.isTemplate,
|
||||
mealItems: input.mealItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface MealItemDto {
|
||||
mealId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
async function getMealItems(planId: string): Promise<PlanItemView[]> {
|
||||
const dtos = await apiRequest<MealItemDto[]>(`client/meal-plans/${planId}/items`)
|
||||
return (dtos ?? []).map((i) => ({
|
||||
id: i.mealId,
|
||||
title: i.name,
|
||||
detail: i.description ?? "",
|
||||
exercises: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function createMealItem(planId: string, title: string, detail: string) {
|
||||
return apiRequest<unknown>(`client/meal-plans/${planId}/items`, {
|
||||
method: "POST",
|
||||
body: { name: title, description: detail },
|
||||
})
|
||||
}
|
||||
|
||||
function deleteMealPlan(id: string) {
|
||||
return apiRequest<unknown>(`client/meal-plans/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
function assignMealPlan(id: string, customerId: string, notes?: string) {
|
||||
return apiRequest<unknown>(`client/meal-plans/${id}/assignments`, {
|
||||
method: "POST",
|
||||
body: { customerId, notes },
|
||||
})
|
||||
}
|
||||
|
||||
function unassignMealPlan(customerMealId: string) {
|
||||
return apiRequest<unknown>(`client/meal-plans/assignments/${customerMealId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
getMealPlans,
|
||||
getMealCategories,
|
||||
createMealCategory,
|
||||
createMealPlan,
|
||||
updateMealPlan,
|
||||
deleteMealPlan,
|
||||
assignMealPlan,
|
||||
unassignMealPlan,
|
||||
getMealItems,
|
||||
createMealItem,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { NavResponse } from "@/lib/models/nav"
|
||||
|
||||
/** The permission-filtered sidebar nav for the current caller (owner sees everything). */
|
||||
function getNav() {
|
||||
return apiRequest<NavResponse>("client/nav")
|
||||
}
|
||||
|
||||
export { getNav }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { PayByLinkInvoice, PayByLinkResult } from "@/lib/models/pay"
|
||||
|
||||
/** Public — resolves a payment-link token to the invoice it's for. */
|
||||
function getInvoiceByPaymentToken(token: string) {
|
||||
return apiRequest<PayByLinkInvoice>(`client/pay/${token}`)
|
||||
}
|
||||
|
||||
/** Public — completes a one-time self-serve payment from the pay-by-link page. */
|
||||
function payInvoiceByToken(token: string, cardToken: string) {
|
||||
return apiRequest<PayByLinkResult>(`client/pay/${token}/charge`, {
|
||||
method: "POST",
|
||||
body: { cardToken },
|
||||
})
|
||||
}
|
||||
|
||||
export { getInvoiceByPaymentToken, payInvoiceByToken }
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { Place, PlaceInput } from "@/lib/models/place"
|
||||
|
||||
function getPlaces(includeInactive = true) {
|
||||
return apiRequest<Place[]>("client/places", { query: { includeInactive } })
|
||||
}
|
||||
|
||||
function createPlace(input: PlaceInput) {
|
||||
return apiRequest<Place>("client/places", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updatePlace(id: string, input: Partial<PlaceInput>) {
|
||||
return apiRequest<Place>(`client/places/${id}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function setPlaceStatus(id: string, isActive: boolean) {
|
||||
return apiRequest<Place>(`client/places/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: { isActive },
|
||||
})
|
||||
}
|
||||
|
||||
function assignPlaceDevice(id: string, deviceId: string | null) {
|
||||
return apiRequest<Place>(`client/places/${id}/device`, {
|
||||
method: "PUT",
|
||||
body: { deviceId },
|
||||
})
|
||||
}
|
||||
|
||||
export { getPlaces, createPlace, updatePlace, setPlaceStatus, assignPlaceDevice }
|
||||
@@ -0,0 +1,98 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { ClientProfile, GymEquipment } from "@/lib/models/profile"
|
||||
|
||||
function getMyProfile() {
|
||||
return apiRequest<ClientProfile>("client/me")
|
||||
}
|
||||
|
||||
export interface GymFacilities {
|
||||
hasAc: boolean
|
||||
equipment: GymEquipment[]
|
||||
}
|
||||
|
||||
/** Facilities is a JSON blob stored in a free-text column; parse defensively. */
|
||||
function parseFacilities(facilities?: string | null): GymFacilities {
|
||||
if (!facilities) return { hasAc: false, equipment: [] }
|
||||
try {
|
||||
const parsed = JSON.parse(facilities) as Partial<GymFacilities>
|
||||
return {
|
||||
hasAc: Boolean(parsed.hasAc),
|
||||
equipment: Array.isArray(parsed.equipment)
|
||||
? parsed.equipment
|
||||
.filter((e): e is GymEquipment => !!e && typeof e.name === "string")
|
||||
.map((e) => ({ name: e.name, count: Number(e.count) || 0 }))
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return { hasAc: false, equipment: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateGymProfileInput {
|
||||
businessName?: string
|
||||
address?: string
|
||||
city?: string
|
||||
description?: string
|
||||
hasAc?: boolean
|
||||
equipment?: GymEquipment[]
|
||||
}
|
||||
|
||||
function updateGymProfile(input: UpdateGymProfileInput) {
|
||||
return apiRequest<{
|
||||
success: boolean
|
||||
message: string
|
||||
facilities?: GymFacilities
|
||||
}>("client/gym-profile", {
|
||||
method: "PUT",
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
function uploadGymLogo(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append("logo", file)
|
||||
return apiRequest<{ businessLogoId: string }>("client/gym-profile/logo", {
|
||||
method: "POST",
|
||||
formData: fd,
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns the logo as a base64 data URL (or null if none) for use as an <img> src. */
|
||||
function getGymLogo() {
|
||||
return apiRequest<{ logo: string | null }>("client/gym-profile/logo")
|
||||
}
|
||||
|
||||
/** Sends an OTP to a NEW email/phone before it replaces the current one. */
|
||||
async function sendContactOtp(
|
||||
type: "email" | "phone",
|
||||
identifier: string
|
||||
): Promise<{ referenceNumber: string }> {
|
||||
const data = await apiRequest<{ referenceNumber: string }>("client/me/contact/otp", {
|
||||
method: "POST",
|
||||
body: { type, identifier },
|
||||
})
|
||||
return { referenceNumber: data.referenceNumber }
|
||||
}
|
||||
|
||||
/** Verifies the OTP and commits the new email/phone to the auth DB + Client record. */
|
||||
function verifyContactOtp(input: {
|
||||
type: "email" | "phone"
|
||||
identifier: string
|
||||
referenceNumber: string
|
||||
otpCode: string
|
||||
}) {
|
||||
return apiRequest<{ success: boolean; message: string }>("client/me/contact/verify", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
getMyProfile,
|
||||
parseFacilities,
|
||||
updateGymProfile,
|
||||
uploadGymLogo,
|
||||
getGymLogo,
|
||||
sendContactOtp,
|
||||
verifyContactOtp,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { clearToken } from "@/lib/auth/token-service"
|
||||
import { resetNavItemsCache } from "@/hooks/use-nav-items"
|
||||
|
||||
/**
|
||||
* Logs out. The JWT is stateless and auth-service exposes no server-side revoke endpoint
|
||||
* (see APIController.cs) — there's nothing to invalidate remotely, so this just clears the
|
||||
* locally stored token. Callers redirect to /login afterwards.
|
||||
*/
|
||||
async function logout() {
|
||||
resetNavItemsCache()
|
||||
clearToken()
|
||||
}
|
||||
|
||||
export { logout }
|
||||
@@ -0,0 +1,84 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
RecordStaffPaymentInput,
|
||||
StaffInput,
|
||||
StaffMember,
|
||||
StaffPayRate,
|
||||
StaffPayment,
|
||||
UpsertStaffPayRateInput,
|
||||
} from "@/lib/models/staff"
|
||||
|
||||
function getStaff() {
|
||||
return apiRequest<StaffMember[]>("client/staff")
|
||||
}
|
||||
|
||||
function getStaffMember(staffId: string) {
|
||||
return apiRequest<StaffMember>(`client/staff/${staffId}`)
|
||||
}
|
||||
|
||||
function createStaff(input: StaffInput) {
|
||||
return apiRequest<unknown>("client/staff", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updateStaff(staffId: string, input: Omit<StaffInput, "hireDate">) {
|
||||
return apiRequest<unknown>(`client/staff/${staffId}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function deleteStaff(staffId: string, softDelete = true) {
|
||||
return apiRequest<unknown>(`client/staff/${staffId}`, {
|
||||
method: "DELETE",
|
||||
query: { softDelete },
|
||||
})
|
||||
}
|
||||
|
||||
function getStaffPayRate(staffId: string) {
|
||||
return apiRequest<StaffPayRate | null>(`client/staff/${staffId}/pay-rate`)
|
||||
}
|
||||
|
||||
function upsertStaffPayRate(staffId: string, input: UpsertStaffPayRateInput) {
|
||||
return apiRequest<StaffPayRate>(`client/staff/${staffId}/pay-rate`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function getStaffPayments(staffId: string) {
|
||||
return apiRequest<StaffPayment[]>(`client/staff/${staffId}/payments`)
|
||||
}
|
||||
|
||||
function recordStaffPayment(staffId: string, input: RecordStaffPaymentInput) {
|
||||
return apiRequest<StaffPayment>(`client/staff/${staffId}/payments`, { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function linkStaffFingerprint(staffId: string, unlinkedEnrollmentId: string) {
|
||||
return apiRequest<{ accessProfileId: number; accessNumber: string; name: string | null }>(
|
||||
`client/staff/${staffId}/link-fingerprint`,
|
||||
{ method: "POST", body: { unlinkedEnrollmentId } }
|
||||
)
|
||||
}
|
||||
|
||||
function lockStaff(staffId: string) {
|
||||
return apiRequest<import("@/lib/models/customer").AccessCommandStatus>(
|
||||
`client/staff/${staffId}/lock`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
function unlockStaff(staffId: string) {
|
||||
return apiRequest<import("@/lib/models/customer").AccessCommandStatus>(
|
||||
`client/staff/${staffId}/unlock`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
getStaff,
|
||||
getStaffMember,
|
||||
createStaff,
|
||||
updateStaff,
|
||||
deleteStaff,
|
||||
getStaffPayRate,
|
||||
upsertStaffPayRate,
|
||||
getStaffPayments,
|
||||
recordStaffPayment,
|
||||
linkStaffFingerprint,
|
||||
lockStaff,
|
||||
unlockStaff,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
ClientStatSummary,
|
||||
MonthlyRevenueStat,
|
||||
RecentAttendance,
|
||||
RecentPayment,
|
||||
} from "@/lib/models/stats"
|
||||
|
||||
/** Date-only, matching the convention already used by src/lib/api/ledger.ts. */
|
||||
function toDateParam(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Omitting from/to falls back to the backend's default (this-month-vs-last-month) window. */
|
||||
function getStatSummary(from?: Date, to?: Date) {
|
||||
return apiRequest<ClientStatSummary>("client/stats/summary", {
|
||||
query: { from: from ? toDateParam(from) : undefined, to: to ? toDateParam(to) : undefined },
|
||||
})
|
||||
}
|
||||
|
||||
function getMonthlyRevenue(from?: Date, to?: Date) {
|
||||
return apiRequest<MonthlyRevenueStat[]>("client/stats/monthly-revenue", {
|
||||
query: { from: from ? toDateParam(from) : undefined, to: to ? toDateParam(to) : undefined },
|
||||
})
|
||||
}
|
||||
|
||||
function getRecentPayments() {
|
||||
return apiRequest<RecentPayment[]>("client/stats/recent-payments")
|
||||
}
|
||||
|
||||
function getRecentAttendance() {
|
||||
return apiRequest<RecentAttendance[]>("client/stats/recent-attendance")
|
||||
}
|
||||
|
||||
export { getStatSummary, getMonthlyRevenue, getRecentPayments, getRecentAttendance }
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
AddExtraFeeInput,
|
||||
MemberCard,
|
||||
RecordPaymentInput,
|
||||
SubscriptionExtraFee,
|
||||
SubscriptionPaymentsResult,
|
||||
SubscriptionSummary,
|
||||
} from "@/lib/models/subscription"
|
||||
|
||||
function getSubscription(id: string) {
|
||||
return apiRequest<SubscriptionSummary>(`client/subscriptions/${id}`)
|
||||
}
|
||||
|
||||
function getSubscriptionPayments(id: string) {
|
||||
return apiRequest<SubscriptionPaymentsResult>(`client/subscriptions/${id}/payments`)
|
||||
}
|
||||
|
||||
function recordSubscriptionPayment(id: string, input: RecordPaymentInput) {
|
||||
// Omit an empty note so the backend's omit-if-null payload stays clean.
|
||||
const body: Record<string, unknown> = { amount: input.amount, paymentMethod: input.paymentMethod }
|
||||
if (input.note && input.note.trim()) body.note = input.note.trim()
|
||||
if (input.paymentMethod === "Bank" && input.bankRefNo && input.bankRefNo.trim())
|
||||
body.bankRefNo = input.bankRefNo.trim()
|
||||
return apiRequest<unknown>(`client/subscriptions/${id}/payments`, { method: "POST", body })
|
||||
}
|
||||
|
||||
function addSubscriptionMember(id: string, customerId: string) {
|
||||
return apiRequest<unknown>(`client/subscriptions/${id}/members`, {
|
||||
method: "POST",
|
||||
body: { customerId },
|
||||
})
|
||||
}
|
||||
|
||||
function removeSubscriptionMember(id: string, subscriptionMemberId: string) {
|
||||
return apiRequest<unknown>(`client/subscriptions/${id}/members/${subscriptionMemberId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
/** Cards a member has saved with this gym (used to offer the auto-charge toggle). */
|
||||
function getMemberCards(customerId: string) {
|
||||
return apiRequest<MemberCard[]>(`client/customers/${customerId}/cards`)
|
||||
}
|
||||
|
||||
function getSubscriptionExtraFees(id: string) {
|
||||
return apiRequest<SubscriptionExtraFee[]>(`client/subscriptions/${id}/extra-fees`)
|
||||
}
|
||||
|
||||
function addSubscriptionExtraFee(id: string, input: AddExtraFeeInput) {
|
||||
return apiRequest<{ customerSubItemId: string; customerInvoiceId: string; amount: number; frequency: string }>(
|
||||
`client/subscriptions/${id}/extra-fees`,
|
||||
{ method: "POST", body: input }
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
getSubscription,
|
||||
getSubscriptionPayments,
|
||||
recordSubscriptionPayment,
|
||||
addSubscriptionMember,
|
||||
removeSubscriptionMember,
|
||||
getMemberCards,
|
||||
getSubscriptionExtraFees,
|
||||
addSubscriptionExtraFee,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
Ticket,
|
||||
TicketCategory,
|
||||
TicketDetail,
|
||||
} from "@/lib/models/ticket"
|
||||
|
||||
function getTickets() {
|
||||
return apiRequest<Ticket[]>("client/tickets")
|
||||
}
|
||||
|
||||
function getTicket(id: string) {
|
||||
return apiRequest<TicketDetail>(`client/tickets/${id}`)
|
||||
}
|
||||
|
||||
function getTicketCategories() {
|
||||
return apiRequest<TicketCategory[]>("client/tickets/categories")
|
||||
}
|
||||
|
||||
function createTicket(mainReason: string, ticketCategoryId?: string) {
|
||||
return apiRequest<unknown>("client/tickets", {
|
||||
method: "POST",
|
||||
body: { mainReason, ticketCategoryId },
|
||||
})
|
||||
}
|
||||
|
||||
function sendTicketMessage(id: string, message: string) {
|
||||
return apiRequest<unknown>(`client/tickets/${id}/messages`, {
|
||||
method: "POST",
|
||||
body: { message },
|
||||
})
|
||||
}
|
||||
|
||||
function submitTicketFeedback(id: string, rating: number, feedback?: string) {
|
||||
return apiRequest<unknown>(`client/tickets/${id}/feedback`, {
|
||||
method: "POST",
|
||||
body: { rating, feedback },
|
||||
})
|
||||
}
|
||||
|
||||
function closeTicket(id: string) {
|
||||
return apiRequest<unknown>(`client/tickets/${id}/close`, { method: "POST" })
|
||||
}
|
||||
|
||||
function deleteTicket(id: string) {
|
||||
return apiRequest<unknown>(`client/tickets/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export {
|
||||
getTickets,
|
||||
getTicket,
|
||||
getTicketCategories,
|
||||
createTicket,
|
||||
sendTicketMessage,
|
||||
submitTicketFeedback,
|
||||
closeTicket,
|
||||
deleteTicket,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
IssueTimeBankCardInput,
|
||||
SwipeTimeBankCardInput,
|
||||
TimeBankCard,
|
||||
} from "@/lib/models/time-bank-card"
|
||||
|
||||
function issueTimeBankCard(input: IssueTimeBankCardInput) {
|
||||
return apiRequest<TimeBankCard>("client/time-bank-cards", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
/** Manual staff-terminal swipe — tap-in starts the clock, tap-out deducts elapsed minutes. */
|
||||
function swipeTimeBankCard(input: SwipeTimeBankCardInput) {
|
||||
return apiRequest<TimeBankCard>("client/time-bank-cards/redeem", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function getTimeBankCardBalance(rfidTagId: string) {
|
||||
return apiRequest<TimeBankCard>(`client/time-bank-cards/${encodeURIComponent(rfidTagId)}`)
|
||||
}
|
||||
|
||||
export { issueTimeBankCard, swipeTimeBankCard, getTimeBankCardBalance }
|
||||
@@ -0,0 +1,181 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { CreatePlanInput, PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
|
||||
|
||||
interface WorkoutPlanDto {
|
||||
workoutPlanId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
isTemplate: boolean
|
||||
workoutCategoryId: string
|
||||
workoutCategoryName?: string | null
|
||||
clientId?: string | null
|
||||
clientName?: string | null
|
||||
isActive: boolean
|
||||
createDate: string
|
||||
assignedCustomers?: Array<{
|
||||
customerWorkoutId: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
isActive: boolean
|
||||
assignedDate: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface WorkoutCategoryDto {
|
||||
workoutCategoryId: string
|
||||
// The backend category DTO exposes the label as `name` (not `workoutCategoryName`).
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
function normalizePlan(dto: WorkoutPlanDto): TrainingPlan {
|
||||
return {
|
||||
planId: dto.workoutPlanId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
isTemplate: dto.isTemplate,
|
||||
categoryId: dto.workoutCategoryId,
|
||||
categoryName: dto.workoutCategoryName,
|
||||
clientId: dto.clientId,
|
||||
clientName: dto.clientName,
|
||||
isActive: dto.isActive,
|
||||
createDate: dto.createDate,
|
||||
assignedCustomers: (dto.assignedCustomers ?? []).map((c) => ({
|
||||
assignmentId: c.customerWorkoutId,
|
||||
customerId: c.customerId,
|
||||
customerName: c.customerName,
|
||||
isActive: c.isActive,
|
||||
assignedDate: c.assignedDate,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorkoutPlans(): Promise<TrainingPlan[]> {
|
||||
const dtos = await apiRequest<WorkoutPlanDto[]>("client/workout-plans")
|
||||
return (dtos ?? []).map(normalizePlan)
|
||||
}
|
||||
|
||||
async function getWorkoutCategories(): Promise<PlanCategory[]> {
|
||||
const dtos = await apiRequest<WorkoutCategoryDto[]>("client/workout-plans/categories")
|
||||
return (dtos ?? []).map((c) => ({
|
||||
categoryId: c.workoutCategoryId,
|
||||
categoryName: c.name,
|
||||
description: c.description,
|
||||
}))
|
||||
}
|
||||
|
||||
function createWorkoutCategory(name: string, description?: string) {
|
||||
return apiRequest<unknown>("client/workout-plans/categories", {
|
||||
method: "POST",
|
||||
body: { name, description },
|
||||
})
|
||||
}
|
||||
|
||||
/** items: array of { exerciseDay, exercises } passed through to the service. */
|
||||
function createWorkoutPlan(input: CreatePlanInput & { workoutItems?: unknown[] }) {
|
||||
return apiRequest<unknown>("client/workout-plans", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
categoryId: undefined,
|
||||
workoutCategoryId: input.categoryId,
|
||||
isTemplate: input.isTemplate,
|
||||
customerIds: input.customerIds,
|
||||
workoutItems: input.workoutItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateWorkoutPlan(
|
||||
id: string,
|
||||
input: Partial<CreatePlanInput> & { workoutItems?: unknown[] }
|
||||
) {
|
||||
return apiRequest<unknown>(`client/workout-plans/${id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
workoutCategoryId: input.categoryId,
|
||||
isTemplate: input.isTemplate,
|
||||
workoutItems: input.workoutItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface WorkoutExerciseDto {
|
||||
workoutExerciseId: string
|
||||
name: string
|
||||
muscleGroup?: string | null
|
||||
sets?: number | null
|
||||
reps?: string | null
|
||||
description?: string | null
|
||||
breathIn?: string | null
|
||||
breathOut?: string | null
|
||||
videoUrl?: string | null
|
||||
}
|
||||
|
||||
interface WorkoutItemDto {
|
||||
workoutItemId: string
|
||||
exerciseDay: string
|
||||
description?: string | null
|
||||
exercises?: string | null
|
||||
workoutExercises?: WorkoutExerciseDto[] | null
|
||||
}
|
||||
|
||||
async function getWorkoutItems(planId: string): Promise<PlanItemView[]> {
|
||||
const dtos = await apiRequest<WorkoutItemDto[]>(`client/workout-plans/${planId}/items`)
|
||||
return (dtos ?? []).map((i) => ({
|
||||
id: i.workoutItemId,
|
||||
title: i.exerciseDay,
|
||||
detail: i.description ?? (typeof i.exercises === "string" ? i.exercises : "") ?? "",
|
||||
exercises: (i.workoutExercises ?? []).map((e) => ({
|
||||
id: e.workoutExerciseId,
|
||||
name: e.name,
|
||||
muscleGroup: e.muscleGroup,
|
||||
sets: e.sets,
|
||||
reps: e.reps,
|
||||
description: e.description,
|
||||
breathIn: e.breathIn,
|
||||
breathOut: e.breathOut,
|
||||
videoUrl: e.videoUrl,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function createWorkoutItem(planId: string, title: string, detail: string) {
|
||||
return apiRequest<unknown>(`client/workout-plans/${planId}/items`, {
|
||||
method: "POST",
|
||||
body: { exerciseDay: title, exercises: detail },
|
||||
})
|
||||
}
|
||||
|
||||
function deleteWorkoutPlan(id: string) {
|
||||
return apiRequest<unknown>(`client/workout-plans/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
function assignWorkoutPlan(id: string, customerId: string, notes?: string) {
|
||||
return apiRequest<unknown>(`client/workout-plans/${id}/assignments`, {
|
||||
method: "POST",
|
||||
body: { customerId, notes },
|
||||
})
|
||||
}
|
||||
|
||||
function unassignWorkoutPlan(customerWorkoutId: string) {
|
||||
return apiRequest<unknown>(`client/workout-plans/assignments/${customerWorkoutId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
getWorkoutPlans,
|
||||
getWorkoutCategories,
|
||||
createWorkoutCategory,
|
||||
createWorkoutPlan,
|
||||
updateWorkoutPlan,
|
||||
deleteWorkoutPlan,
|
||||
assignWorkoutPlan,
|
||||
unassignWorkoutPlan,
|
||||
getWorkoutItems,
|
||||
createWorkoutItem,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// core-api authenticates via a `MobileAuthToken: Bearer <token>` header (see
|
||||
// core-http.ts), not an httpOnly cookie -- there's no BFF/gateway in this deployment to
|
||||
// set one. The JWT itself is what login() returns; this just persists it across page
|
||||
// reloads. An in-memory mirror avoids a localStorage read on every single request.
|
||||
const TOKEN_KEY = "vijaya_access_token"
|
||||
|
||||
let memoryToken: string | null = null
|
||||
|
||||
function setToken(token: string) {
|
||||
memoryToken = token
|
||||
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
memoryToken = null
|
||||
if (typeof window !== "undefined") localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (memoryToken) return memoryToken
|
||||
if (typeof window === "undefined") return null
|
||||
memoryToken = localStorage.getItem(TOKEN_KEY)
|
||||
return memoryToken
|
||||
}
|
||||
|
||||
function isLoggedIn() {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
export { setToken, clearToken, getToken, isLoggedIn }
|
||||
@@ -0,0 +1,53 @@
|
||||
export type PeriodKey = "today" | "yesterday" | "7d" | "30d" | "3mo" | "6mo"
|
||||
|
||||
export const PERIOD_OPTIONS: { value: PeriodKey; label: string }[] = [
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "yesterday", label: "Yesterday" },
|
||||
{ value: "7d", label: "7d" },
|
||||
{ value: "30d", label: "30d" },
|
||||
{ value: "3mo", label: "3mo" },
|
||||
{ value: "6mo", label: "6mo" },
|
||||
]
|
||||
|
||||
function startOfDay(d: Date) {
|
||||
const copy = new Date(d)
|
||||
copy.setHours(0, 0, 0, 0)
|
||||
return copy
|
||||
}
|
||||
|
||||
/** Concrete {from, to} (both inclusive, local calendar days) for the selected period tab. */
|
||||
function getPeriodRange(period: PeriodKey): { from: Date; to: Date } {
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
switch (period) {
|
||||
case "today":
|
||||
return { from: today, to: today }
|
||||
case "yesterday": {
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return { from: yesterday, to: yesterday }
|
||||
}
|
||||
case "7d": {
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 6)
|
||||
return { from, to: today }
|
||||
}
|
||||
case "30d": {
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 29)
|
||||
return { from, to: today }
|
||||
}
|
||||
case "3mo": {
|
||||
const from = new Date(today)
|
||||
from.setMonth(from.getMonth() - 3)
|
||||
return { from, to: today }
|
||||
}
|
||||
case "6mo": {
|
||||
const from = new Date(today)
|
||||
from.setMonth(from.getMonth() - 6)
|
||||
return { from, to: today }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { getPeriodRange }
|
||||
@@ -0,0 +1,341 @@
|
||||
import type {
|
||||
AttendanceCount,
|
||||
AttendanceRecord,
|
||||
TodayAttendance,
|
||||
} from "@/lib/models/attendance"
|
||||
import type { FollowedPlan, MedicalDetails, MemberProfile } from "@/lib/models/customer"
|
||||
import type { GymPlan } from "@/lib/models/gym-plan"
|
||||
import type { ProfitLoss } from "@/lib/models/ledger"
|
||||
import type { PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
|
||||
import type { ClientProfile } from "@/lib/models/profile"
|
||||
import type { ClientStatSummary, MonthlyRevenueStat, RecentPayment } from "@/lib/models/stats"
|
||||
import type { SubscriptionPayment, SubscriptionSummary } from "@/lib/models/subscription"
|
||||
import type { Ticket, TicketCategory, TicketMessage } from "@/lib/models/ticket"
|
||||
|
||||
/** Shared mock datasets that back the sandbox/demo pages (no API calls). */
|
||||
|
||||
/** Membership plans behind the Members stage's Assign/Change/Onboarding dialogs. */
|
||||
export const DEMO_GYM_PLANS: GymPlan[] = [
|
||||
{ customerPlanId: "demo-plan-basic", fkClientId: "demo-gym-pulse-fitness", planName: "Basic Monthly", planDescription: "Gym floor access only", planPrice: 6000, hasOnboardingFee: false, onboardingFeeAmount: 0, durationInDays: 30, planScope: "Individual", memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, installmentCount: 1, isActive: true, createdAt: "2026-01-05T09:00:00+05:30", placeIds: [], placeNames: [] },
|
||||
{ customerPlanId: "demo-plan-premium", fkClientId: "demo-gym-pulse-fitness", planName: "Premium Monthly", planDescription: "Gym floor + group classes", planPrice: 9500, hasOnboardingFee: true, onboardingFeeAmount: 1000, durationInDays: 30, planScope: "Individual", memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, installmentCount: 1, isActive: true, createdAt: "2026-01-10T09:00:00+05:30", placeIds: [], placeNames: [] },
|
||||
{ customerPlanId: "demo-plan-couple", fkClientId: "demo-gym-pulse-fitness", planName: "Couple Quarterly", planDescription: "Gym + group classes for two", planPrice: 24000, hasOnboardingFee: false, onboardingFeeAmount: 0, durationInDays: 90, planScope: "Group", memberLimit: 2, durationMode: "Rolling", allowSplitPayments: true, installmentCount: 3, isActive: true, createdAt: "2026-01-15T09:00:00+05:30", placeIds: [], placeNames: [] },
|
||||
{ customerPlanId: "demo-plan-family", fkClientId: "demo-gym-pulse-fitness", planName: "Family Season Pass", planDescription: "Whole-family access", planPrice: 40000, hasOnboardingFee: false, onboardingFeeAmount: 0, durationInDays: 0, planScope: "Group", memberLimit: 5, durationMode: "FixedRange", validFrom: "2026-08-01", validTo: "2026-12-31", allowSplitPayments: false, installmentCount: 1, isActive: true, createdAt: "2026-01-20T09:00:00+05:30", placeIds: [], placeNames: [] },
|
||||
]
|
||||
|
||||
/** Every demo customer's profile — the source of truth the Members stage's rows are derived from. */
|
||||
export const DEMO_MEMBER_PROFILES: MemberProfile[] = [
|
||||
{ cusId: "d1", cusName: "Kasun Perera", gender: "Male", boD: "1995-03-12", primaryPH: "077 123 4567", email: "kasun@example.com", nic: "199512345678", isActive: true, createdAt: "2026-06-02T09:00:00+05:30", isLocked: false, user: { userId: "u-d1", userName: "kasun.perera", email: "kasun@example.com", phoneNumber: "077 123 4567" } },
|
||||
{ cusId: "d2", cusName: "Nadeesha Silva", gender: "Female", boD: "1996-07-22", primaryPH: "071 987 6543", email: "nadeesha@example.com", nic: "966542310V", isActive: true, createdAt: "2026-05-18T09:00:00+05:30", isLocked: false, user: { userId: "u-d2", userName: "nadeesha.silva", email: "nadeesha@example.com", phoneNumber: "071 987 6543" } },
|
||||
{ cusId: "d2b", cusName: "Chamara Silva", gender: "Male", boD: "1994-11-02", primaryPH: "071 555 2211", email: "chamara@example.com", nic: "942271234V", isActive: true, createdAt: "2026-05-18T09:00:00+05:30", isLocked: false, user: { userId: "u-d2b", userName: "chamara.silva", email: "chamara@example.com", phoneNumber: "071 555 2211" } },
|
||||
{ cusId: "d3", cusName: "Ashan Fernando", gender: "Male", boD: "1988-01-30", primaryPH: "076 555 1212", email: "ashan@example.com", nic: "199801240036", isActive: true, createdAt: "2026-07-01T09:00:00+05:30", isLocked: false, user: { userId: "u-d3", userName: "ashan.fernando", email: "ashan@example.com", phoneNumber: "076 555 1212" } },
|
||||
{ cusId: "d3b", cusName: "Sanduni Fernando", gender: "Female", boD: "1990-04-18", primaryPH: "076 555 1213", email: "sanduni@example.com", nic: "199032500012", isActive: true, createdAt: "2026-07-01T09:00:00+05:30", isLocked: false, user: { userId: "u-d3b", userName: "sanduni.fernando", email: "sanduni@example.com", phoneNumber: "076 555 1213" } },
|
||||
{ cusId: "d3c", cusName: "Dilan Fernando", gender: "Male", boD: "2014-09-09", primaryPH: "076 555 1214", email: "dilan.fernando@example.com", nic: null, isActive: true, createdAt: "2026-07-01T09:00:00+05:30", isLocked: false, user: { userId: "u-d3c", userName: "dilan.fernando", email: "dilan.fernando@example.com", phoneNumber: "076 555 1214" } },
|
||||
{ cusId: "d4", cusName: "Ishara Jayawardena", gender: "Female", boD: "1997-02-14", primaryPH: "070 444 8899", email: "ishara@example.com", nic: "925432112V", isActive: true, createdAt: "2026-06-25T09:00:00+05:30", isLocked: false, user: { userId: "u-d4", userName: "ishara.jaya", email: "ishara@example.com", phoneNumber: "070 444 8899" } },
|
||||
{ cusId: "d5", cusName: "Dinusha Bandara", gender: "Male", boD: "1999-05-05", primaryPH: "072 333 7788", email: "dinusha@example.com", nic: "200012345671", isActive: true, createdAt: "2026-08-15T09:00:00+05:30", isLocked: false, user: { userId: "u-d5", userName: "dinusha.bandara", email: "dinusha@example.com", phoneNumber: "072 333 7788" } },
|
||||
{ cusId: "d6", cusName: "Malith Fernando", gender: "Male", boD: "1992-10-19", primaryPH: "075 222 6633", email: "malith@example.com", nic: "926691234V", isActive: true, createdAt: "2026-04-05T09:00:00+05:30", isLocked: true, lockReason: "Overdue balance", user: { userId: "u-d6", userName: "malith.fernando", email: "malith@example.com", phoneNumber: "075 222 6633" } },
|
||||
]
|
||||
|
||||
/** One `SubscriptionSummary` per active membership; `d5` is intentionally left without one (planScope "None"). */
|
||||
export const DEMO_SUBSCRIPTIONS: SubscriptionSummary[] = [
|
||||
{
|
||||
subscriptionId: "sub-d1", customerPlanId: "demo-plan-basic", planName: "Basic Monthly", planScope: "Individual",
|
||||
memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, planPrice: 6000,
|
||||
onboardDate: "2026-06-02", expiryDate: "2026-08-19", isActive: true,
|
||||
customerInvoiceId: "inv-d1", invoiceAmount: 6000, paidAmount: 6000, balance: 0, paymentStatus: "Paid",
|
||||
members: [{ subscriptionMemberId: "sm-d1", customerId: "d1", cusName: "Kasun Perera", email: "kasun@example.com", primaryPH: "077 123 4567", isPrimary: true, joinedAt: "2026-06-02", isActive: true }],
|
||||
installments: [{ subscriptionInstallmentId: "inst-d1-1", installmentNo: 1, dueDate: "2026-06-02", amount: 6000, paidAmount: 6000, status: "Paid" }],
|
||||
autoChargeEnabled: false, autoChargeCard: null,
|
||||
},
|
||||
{
|
||||
subscriptionId: "sub-d2", customerPlanId: "demo-plan-couple", planName: "Couple Quarterly", planScope: "Group",
|
||||
memberLimit: 2, durationMode: "Rolling", allowSplitPayments: true, planPrice: 24000,
|
||||
onboardDate: "2026-05-18", expiryDate: "2026-08-18", isActive: true,
|
||||
customerInvoiceId: "inv-d2", invoiceAmount: 24000, paidAmount: 16000, balance: 8000, paymentStatus: "Partial",
|
||||
members: [
|
||||
{ subscriptionMemberId: "sm-d2", customerId: "d2", cusName: "Nadeesha Silva", email: "nadeesha@example.com", primaryPH: "071 987 6543", isPrimary: true, joinedAt: "2026-05-18", isActive: true },
|
||||
{ subscriptionMemberId: "sm-d2b", customerId: "d2b", cusName: "Chamara Silva", email: "chamara@example.com", primaryPH: "071 555 2211", isPrimary: false, joinedAt: "2026-05-18", isActive: true },
|
||||
],
|
||||
installments: [
|
||||
{ subscriptionInstallmentId: "inst-d2-1", installmentNo: 1, dueDate: "2026-05-18", amount: 8000, paidAmount: 8000, status: "Paid" },
|
||||
{ subscriptionInstallmentId: "inst-d2-2", installmentNo: 2, dueDate: "2026-06-18", amount: 8000, paidAmount: 8000, status: "Paid" },
|
||||
{ subscriptionInstallmentId: "inst-d2-3", installmentNo: 3, dueDate: "2026-07-18", amount: 8000, paidAmount: 0, status: "Pending" },
|
||||
],
|
||||
autoChargeEnabled: true, autoChargeCard: { cardId: "card-d2", cardLast4: "4242", cardType: "Visa" },
|
||||
},
|
||||
{
|
||||
subscriptionId: "sub-d3", customerPlanId: "demo-plan-family", planName: "Family Season Pass", planScope: "Group",
|
||||
memberLimit: 5, durationMode: "FixedRange", allowSplitPayments: false, planPrice: 40000,
|
||||
onboardDate: "2026-07-01", expiryDate: "2026-12-31", isActive: true,
|
||||
customerInvoiceId: "inv-d3", invoiceAmount: 40000, paidAmount: 0, balance: 40000, paymentStatus: "Pending",
|
||||
members: [
|
||||
{ subscriptionMemberId: "sm-d3", customerId: "d3", cusName: "Ashan Fernando", email: "ashan@example.com", primaryPH: "076 555 1212", isPrimary: true, joinedAt: "2026-07-01", isActive: true },
|
||||
{ subscriptionMemberId: "sm-d3b", customerId: "d3b", cusName: "Sanduni Fernando", email: "sanduni@example.com", primaryPH: "076 555 1213", isPrimary: false, joinedAt: "2026-07-01", isActive: true },
|
||||
{ subscriptionMemberId: "sm-d3c", customerId: "d3c", cusName: "Dilan Fernando", email: "dilan.fernando@example.com", primaryPH: "076 555 1214", isPrimary: false, joinedAt: "2026-07-01", isActive: true },
|
||||
],
|
||||
installments: [{ subscriptionInstallmentId: "inst-d3-1", installmentNo: 1, dueDate: "2026-07-01", amount: 40000, paidAmount: 0, status: "Pending" }],
|
||||
autoChargeEnabled: false, autoChargeCard: null,
|
||||
},
|
||||
{
|
||||
subscriptionId: "sub-d4", customerPlanId: "demo-plan-basic", planName: "Basic Monthly", planScope: "Individual",
|
||||
memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, planPrice: 6000,
|
||||
onboardDate: "2026-06-25", expiryDate: "2026-08-25", isActive: true,
|
||||
customerInvoiceId: "inv-d4", invoiceAmount: 6000, paidAmount: 6000, balance: 0, paymentStatus: "Paid",
|
||||
members: [{ subscriptionMemberId: "sm-d4", customerId: "d4", cusName: "Ishara Jayawardena", email: "ishara@example.com", primaryPH: "070 444 8899", isPrimary: true, joinedAt: "2026-06-25", isActive: true }],
|
||||
installments: [{ subscriptionInstallmentId: "inst-d4-1", installmentNo: 1, dueDate: "2026-06-25", amount: 6000, paidAmount: 6000, status: "Paid" }],
|
||||
autoChargeEnabled: false, autoChargeCard: null,
|
||||
},
|
||||
{
|
||||
subscriptionId: "sub-d6", customerPlanId: "demo-plan-premium", planName: "Premium Monthly", planScope: "Individual",
|
||||
memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, planPrice: 9500,
|
||||
onboardDate: "2026-04-05", expiryDate: "2026-07-05", isActive: true,
|
||||
customerInvoiceId: "inv-d6", invoiceAmount: 9500, paidAmount: 0, balance: 9500, paymentStatus: "Pending",
|
||||
members: [{ subscriptionMemberId: "sm-d6", customerId: "d6", cusName: "Malith Fernando", email: "malith@example.com", primaryPH: "075 222 6633", isPrimary: true, joinedAt: "2026-04-05", isActive: true }],
|
||||
installments: [{ subscriptionInstallmentId: "inst-d6-1", installmentNo: 1, dueDate: "2026-04-05", amount: 9500, paidAmount: 0, status: "Pending" }],
|
||||
autoChargeEnabled: false, autoChargeCard: null,
|
||||
},
|
||||
]
|
||||
|
||||
/** Seeds "More info" (workout/meal plans + medical) per customer; missing ids fall back to empty. */
|
||||
export const DEMO_MEMBER_EXTRAS: Record<string, { workoutPlans: FollowedPlan[]; mealPlans: FollowedPlan[]; medical: MedicalDetails | null }> = {
|
||||
d1: {
|
||||
workoutPlans: [{ planId: "w1", name: "Beginner Full Body", isFollowing: true, completedDate: null }],
|
||||
mealPlans: [{ planId: "m2", name: "Cutting 1800kcal", isFollowing: true, completedDate: null }],
|
||||
medical: { bloodGroup: "O+", disabilities: null },
|
||||
},
|
||||
d2: {
|
||||
workoutPlans: [{ planId: "w2", name: "Fat Loss HIIT", isFollowing: true, completedDate: null }],
|
||||
mealPlans: [],
|
||||
medical: { bloodGroup: "A+", disabilities: null },
|
||||
},
|
||||
d3: {
|
||||
workoutPlans: [],
|
||||
mealPlans: [{ planId: "m1", name: "Lean Bulk 2500kcal", isFollowing: false, completedDate: null }],
|
||||
medical: { bloodGroup: null, disabilities: "Mild knee injury — avoid heavy squats." },
|
||||
},
|
||||
}
|
||||
|
||||
/** Per-customer fingerprint/access panel fields (joined onto rows the same way the backend does). */
|
||||
export const DEMO_MEMBER_ACCESS: Record<string, {
|
||||
accessNumber: string
|
||||
accessProvisionStatus: "NotConfigured" | "NoDevice" | "Pending" | "Synced" | "Failed"
|
||||
fingerprintCount: number
|
||||
actualAccessState: "Unknown" | "Enabled" | "Disabled" | "Mixed"
|
||||
desiredAccessState: "Enabled" | "Disabled"
|
||||
}> = {
|
||||
d1: { accessNumber: "101", accessProvisionStatus: "Synced", fingerprintCount: 2, actualAccessState: "Enabled", desiredAccessState: "Enabled" },
|
||||
d2: { accessNumber: "102", accessProvisionStatus: "Synced", fingerprintCount: 1, actualAccessState: "Enabled", desiredAccessState: "Enabled" },
|
||||
d2b: { accessNumber: "102b", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
|
||||
d3: { accessNumber: "103", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
|
||||
d3b: { accessNumber: "103b", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
|
||||
d3c: { accessNumber: "103c", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
|
||||
d4: { accessNumber: "104", accessProvisionStatus: "Synced", fingerprintCount: 2, actualAccessState: "Enabled", desiredAccessState: "Enabled" },
|
||||
d6: { accessNumber: "106", accessProvisionStatus: "Synced", fingerprintCount: 1, actualAccessState: "Disabled", desiredAccessState: "Disabled" },
|
||||
}
|
||||
|
||||
/** Payment-history rows behind `MemberSubscriptionCard`'s "Payment history" accordion, keyed by subscriptionId. */
|
||||
export const DEMO_SUBSCRIPTION_PAYMENTS: Record<string, SubscriptionPayment[]> = {
|
||||
"sub-d1": [{ attemptId: "pay-d1-1", payDate: "2026-06-02T10:05:00+05:30", payStatus: "Success", payAmount: 6000, payNote: "Cash at counter" }],
|
||||
"sub-d2": [
|
||||
{ attemptId: "pay-d2-1", payDate: "2026-05-18T09:40:00+05:30", payStatus: "Success", payAmount: 8000, payNote: "Card" },
|
||||
{ attemptId: "pay-d2-2", payDate: "2026-06-18T09:50:00+05:30", payStatus: "Success", payAmount: 8000, payNote: "Card" },
|
||||
],
|
||||
"sub-d4": [{ attemptId: "pay-d4-1", payDate: "2026-06-25T11:10:00+05:30", payStatus: "Success", payAmount: 6000, payNote: "Cash at counter" }],
|
||||
}
|
||||
|
||||
export const DEMO_REVENUE: MonthlyRevenueStat[] = [
|
||||
{ year: 2026, month: 2, monthLabel: "Feb", revenue: 118000, newMemberCount: 9 },
|
||||
{ year: 2026, month: 3, monthLabel: "Mar", revenue: 134000, newMemberCount: 11 },
|
||||
{ year: 2026, month: 4, monthLabel: "Apr", revenue: 129000, newMemberCount: 8 },
|
||||
{ year: 2026, month: 5, monthLabel: "May", revenue: 152000, newMemberCount: 13 },
|
||||
{ year: 2026, month: 6, monthLabel: "Jun", revenue: 168000, newMemberCount: 15 },
|
||||
{ year: 2026, month: 7, monthLabel: "Jul", revenue: 96000, newMemberCount: 7 },
|
||||
]
|
||||
|
||||
/** Matches the real `ClientStatSummary` shape (GET client/stats/summary) exactly. */
|
||||
export const DEMO_STAT_SUMMARY: ClientStatSummary = {
|
||||
totalCustomerCount: 128,
|
||||
thisMonthCustomerCount: 14,
|
||||
lastMonthCustomerCount: 12,
|
||||
customerGrowthPercentage: 12.0,
|
||||
thisMonthRevenue: 168000,
|
||||
lastMonthRevenue: 153500,
|
||||
revenueGrowthPercentage: 9.4,
|
||||
totalRevenue: 1845000,
|
||||
overdueCount: 6,
|
||||
expiringWithin7DaysCount: 9,
|
||||
todayPaymentsCount: 4,
|
||||
todayPaymentsTotal: 22000,
|
||||
showTodayPayments: false,
|
||||
}
|
||||
|
||||
export const DEMO_TODAY_ATTENDANCE: TodayAttendance[] = [
|
||||
{ customerName: "Dinusha Bandara", eventTime: "2026-07-28T18:42:00+05:30", eventType: "IN" },
|
||||
{ customerName: "Ishara Jayawardena", eventTime: "2026-07-28T17:56:00+05:30", eventType: "OUT" },
|
||||
{ customerName: "Ashan Fernando", eventTime: "2026-07-28T17:31:00+05:30", eventType: "IN" },
|
||||
{ customerName: "Nadeesha Silva", eventTime: "2026-07-28T08:05:00+05:30", eventType: "IN" },
|
||||
{ customerName: "Kasun Perera", eventTime: "2026-07-28T07:18:00+05:30", eventType: "IN" },
|
||||
{ customerName: "Malith Fernando", eventTime: "2026-07-28T06:48:00+05:30", eventType: "OUT" },
|
||||
]
|
||||
|
||||
export const DEMO_ATTENDANCE_HISTORY: AttendanceRecord[] = [
|
||||
{ attendanceId: "a1", fkCustomerId: "d5", deviceUserId: 105, eventTime: "2026-07-28T18:42:00+05:30", eventType: "IN", customer: { cusId: "d5", cusName: "Dinusha Bandara" } },
|
||||
{ attendanceId: "a2", fkCustomerId: "d4", deviceUserId: 104, eventTime: "2026-07-28T17:56:00+05:30", eventType: "OUT", customer: { cusId: "d4", cusName: "Ishara Jayawardena" } },
|
||||
{ attendanceId: "a3", fkCustomerId: "d3", deviceUserId: 103, eventTime: "2026-07-28T17:31:00+05:30", eventType: "IN", customer: { cusId: "d3", cusName: "Ashan Fernando" } },
|
||||
{ attendanceId: "a4", fkCustomerId: "d2", deviceUserId: 102, eventTime: "2026-07-28T08:05:00+05:30", eventType: "IN", customer: { cusId: "d2", cusName: "Nadeesha Silva" } },
|
||||
{ attendanceId: "a5", fkCustomerId: "d1", deviceUserId: 101, eventTime: "2026-07-28T07:18:00+05:30", eventType: "IN", customer: { cusId: "d1", cusName: "Kasun Perera" } },
|
||||
{ attendanceId: "a6", fkCustomerId: "d1", deviceUserId: 101, eventTime: "2026-07-27T19:46:00+05:30", eventType: "OUT", customer: { cusId: "d1", cusName: "Kasun Perera" } },
|
||||
{ attendanceId: "a7", fkCustomerId: "d2", deviceUserId: 102, eventTime: "2026-07-27T18:34:00+05:30", eventType: "OUT", customer: { cusId: "d2", cusName: "Nadeesha Silva" } },
|
||||
{ attendanceId: "a8", fkCustomerId: "d5", deviceUserId: 105, eventTime: "2026-07-27T06:22:00+05:30", eventType: "IN", customer: { cusId: "d5", cusName: "Dinusha Bandara" } },
|
||||
]
|
||||
|
||||
export const DEMO_ATTENDANCE_COUNTS: AttendanceCount[] = [
|
||||
{ customerId: "d1", customerName: "Kasun Perera", attendanceCount: 24 },
|
||||
{ customerId: "d2", customerName: "Nadeesha Silva", attendanceCount: 22 },
|
||||
{ customerId: "d5", customerName: "Dinusha Bandara", attendanceCount: 20 },
|
||||
{ customerId: "d4", customerName: "Ishara Jayawardena", attendanceCount: 17 },
|
||||
{ customerId: "d3", customerName: "Ashan Fernando", attendanceCount: 14 },
|
||||
]
|
||||
|
||||
export const DEMO_RECENT_PAYMENTS: RecentPayment[] = [
|
||||
{ attemptId: "p1", payDate: "2026-07-28T16:20:00+05:30", payStatus: "Success", payAmount: 6000, customer: { cusId: "d1", cusName: "Kasun Perera" } },
|
||||
{ attemptId: "p2", payDate: "2026-07-28T12:15:00+05:30", payStatus: "Success", payAmount: 8000, customer: { cusId: "d2", cusName: "Nadeesha Silva" } },
|
||||
{ attemptId: "p3", payDate: "2026-07-27T18:05:00+05:30", payStatus: "Pending", payAmount: 40000, customer: { cusId: "d3", cusName: "Ashan Fernando" } },
|
||||
]
|
||||
|
||||
export const DEMO_PROFILE: ClientProfile = {
|
||||
clientId: "demo-gym-pulse-fitness",
|
||||
clientName: "Pulse Fitness Colombo",
|
||||
clientPhone: "077 555 0198",
|
||||
clientEmail: "hello@pulsefitness.lk",
|
||||
clientBusinesses: [
|
||||
{
|
||||
bid: "demo-business",
|
||||
businessName: "Pulse Fitness Colombo",
|
||||
address: "42 Havelock Road",
|
||||
city: "Colombo 05",
|
||||
description: "A modern strength, cardio and group-training gym.",
|
||||
facilities: JSON.stringify({
|
||||
hasAc: true,
|
||||
equipment: [
|
||||
{ name: "Treadmill", count: 8 },
|
||||
{ name: "Dumbbell set", count: 4 },
|
||||
{ name: "Squat rack", count: 3 },
|
||||
{ name: "Exercise bike", count: 6 },
|
||||
],
|
||||
}),
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const DEMO_PROFIT_LOSS: ProfitLoss = {
|
||||
from: "2026-07-01",
|
||||
to: "2026-07-28",
|
||||
totalIncome: 168000,
|
||||
totalExpense: 89500,
|
||||
netProfit: 78500,
|
||||
incomeByCategory: [
|
||||
{ category: "Membership payments", amount: 154000 },
|
||||
{ category: "Personal training", amount: 14000 },
|
||||
],
|
||||
expenseByCategory: [
|
||||
{ category: "Rent", amount: 50000 },
|
||||
{ category: "Staff payroll", amount: 28000 },
|
||||
{ category: "Utilities", amount: 7500 },
|
||||
{ category: "Equipment maintenance", amount: 4000 },
|
||||
],
|
||||
entries: [
|
||||
{ id: "l1", entryType: "Income", category: "Membership payments", description: "Member invoice collections", amount: 154000, entryDate: "2026-07-28", isManual: false },
|
||||
{ id: "l2", entryType: "Income", category: "Personal training", description: "PT session income", amount: 14000, entryDate: "2026-07-26", isManual: true },
|
||||
{ id: "l3", entryType: "Expense", category: "Rent", description: "July premises rent", amount: 50000, entryDate: "2026-07-05", isManual: true },
|
||||
{ id: "l4", entryType: "Expense", category: "Staff payroll", description: "Monthly payroll", amount: 28000, entryDate: "2026-07-25", isManual: true },
|
||||
{ id: "l5", entryType: "Expense", category: "Utilities", description: "Electricity and water", amount: 7500, entryDate: "2026-07-22", isManual: true },
|
||||
{ id: "l6", entryType: "Expense", category: "Equipment maintenance", description: "Treadmill service", amount: 4000, entryDate: "2026-07-18", isManual: true },
|
||||
],
|
||||
}
|
||||
|
||||
export const DEMO_WORKOUT_CATEGORIES: PlanCategory[] = [
|
||||
{ categoryId: "wc-1", categoryName: "Strength", description: null },
|
||||
{ categoryId: "wc-2", categoryName: "Cardio", description: null },
|
||||
]
|
||||
|
||||
export const DEMO_MEAL_CATEGORIES: PlanCategory[] = [
|
||||
{ categoryId: "mc-1", categoryName: "Muscle gain", description: null },
|
||||
{ categoryId: "mc-2", categoryName: "Weight loss", description: null },
|
||||
]
|
||||
|
||||
export const DEMO_WORKOUT_TRAINING_PLANS: TrainingPlan[] = [
|
||||
{
|
||||
planId: "wp-1", name: "Beginner Full Body", description: "3-day full body routine for new members",
|
||||
isTemplate: true, categoryId: "wc-1", categoryName: "Strength", isActive: true, createDate: "2026-06-01T09:00:00+05:30",
|
||||
assignedCustomers: [{ assignmentId: "wa-1", customerId: "d1", customerName: "Kasun Perera", isActive: true, assignedDate: "2026-06-05" }],
|
||||
},
|
||||
{
|
||||
planId: "wp-2", name: "Fat Loss HIIT", description: "High intensity interval training",
|
||||
isTemplate: false, categoryId: "wc-2", categoryName: "Cardio", isActive: true, createDate: "2026-06-10T09:00:00+05:30",
|
||||
assignedCustomers: [],
|
||||
},
|
||||
]
|
||||
|
||||
export const DEMO_MEAL_TRAINING_PLANS: TrainingPlan[] = [
|
||||
{
|
||||
planId: "mp-1", name: "Lean Bulk 2500kcal", description: "High protein muscle-gain plan",
|
||||
isTemplate: true, categoryId: "mc-1", categoryName: "Muscle gain", isActive: true, createDate: "2026-06-02T09:00:00+05:30",
|
||||
assignedCustomers: [],
|
||||
},
|
||||
{
|
||||
planId: "mp-2", name: "Cutting 1800kcal", description: "Calorie deficit for fat loss",
|
||||
isTemplate: false, categoryId: "mc-2", categoryName: "Weight loss", isActive: true, createDate: "2026-06-12T09:00:00+05:30",
|
||||
assignedCustomers: [{ assignmentId: "ma-1", customerId: "d4", customerName: "Ishara Jayawardena", isActive: true, assignedDate: "2026-06-15" }],
|
||||
},
|
||||
]
|
||||
|
||||
/** Saved plan days/meals behind PlanDetailPanel and the plan editor's "edit" prefill, keyed by planId. */
|
||||
export const DEMO_PLAN_ITEMS: Record<string, PlanItemView[]> = {
|
||||
"wp-1": [
|
||||
{ id: "wi-1", title: "Monday", detail: "Upper-body strength", exercises: [{ id: "ex-1", name: "Bench Press", muscleGroup: "Chest", sets: 4, reps: "8-10", description: null, breathIn: null, breathOut: null, videoUrl: null }] },
|
||||
{ id: "wi-2", title: "Wednesday", detail: "Lower-body strength", exercises: [{ id: "ex-2", name: "Squat", muscleGroup: "Legs", sets: 4, reps: "8-10", description: null, breathIn: null, breathOut: null, videoUrl: null }] },
|
||||
{ id: "wi-3", title: "Friday", detail: "Conditioning and core", exercises: [{ id: "ex-3", name: "Plank", muscleGroup: "Core", sets: 3, reps: "45s", description: null, breathIn: null, breathOut: null, videoUrl: null }] },
|
||||
],
|
||||
"mp-1": [
|
||||
{ id: "mi-1", title: "Breakfast", detail: "Oats, eggs, banana", exercises: [] },
|
||||
{ id: "mi-2", title: "Lunch", detail: "Rice, chicken breast, vegetables", exercises: [] },
|
||||
{ id: "mi-3", title: "Dinner", detail: "Fish, sweet potato, salad", exercises: [] },
|
||||
],
|
||||
}
|
||||
|
||||
export const DEMO_TICKET_CATEGORIES: TicketCategory[] = [
|
||||
{ ticketCategoryId: "tc-1", ticketCategoryName: "Billing" },
|
||||
{ ticketCategoryId: "tc-2", ticketCategoryName: "Technical" },
|
||||
{ ticketCategoryId: "tc-3", ticketCategoryName: "General" },
|
||||
]
|
||||
|
||||
export const DEMO_TICKETS: Ticket[] = [
|
||||
{
|
||||
ticketId: "ticket-1", mainReason: "Need help setting up member plans", createDate: "2026-08-10T09:15:00+05:30",
|
||||
userId: "demo-owner", ticketCategoryId: "tc-3", ticketCategoryName: "General", ticketStatus: 2,
|
||||
},
|
||||
{
|
||||
ticketId: "ticket-2", mainReason: "Payment gateway not receiving webhooks", createDate: "2026-08-05T14:30:00+05:30",
|
||||
userId: "demo-owner", ticketCategoryId: "tc-2", ticketCategoryName: "Technical", ticketStatus: 1,
|
||||
},
|
||||
{
|
||||
ticketId: "ticket-3", mainReason: "Question about last month's platform invoice", createDate: "2026-07-20T11:00:00+05:30",
|
||||
userId: "demo-owner", ticketCategoryId: "tc-1", ticketCategoryName: "Billing", ticketStatus: 3,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEMO_TICKET_MESSAGES: Record<string, TicketMessage[]> = {
|
||||
"ticket-1": [
|
||||
{ messageId: "msg-1a", isSentByAdmin: false, message: "How do I add a couple/family plan?", createDate: "2026-08-10T09:15:00+05:30" },
|
||||
{ messageId: "msg-1b", isSentByAdmin: true, message: "Head to Memberships → New Plan and set the scope to Group with a member limit.", createDate: "2026-08-10T10:02:00+05:30" },
|
||||
],
|
||||
"ticket-2": [
|
||||
{ messageId: "msg-2a", isSentByAdmin: false, message: "PayHere payments aren't marking invoices as paid automatically.", createDate: "2026-08-05T14:30:00+05:30" },
|
||||
],
|
||||
"ticket-3": [
|
||||
{ messageId: "msg-3a", isSentByAdmin: false, message: "Can you break down last month's usage fee?", createDate: "2026-07-20T11:00:00+05:30" },
|
||||
{ messageId: "msg-3b", isSentByAdmin: true, message: "It's Rs. 50 per active member on top of the base platform fee — 90 members last month.", createDate: "2026-07-20T13:45:00+05:30" },
|
||||
{ messageId: "msg-3c", isSentByAdmin: false, message: "Got it, thanks!", createDate: "2026-07-20T14:00:00+05:30" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Client-side normalisation for face photos headed to a Hikvision face library.
|
||||
*
|
||||
* The terminal is strict in ways a raw camera roll photo never satisfies, so the browser
|
||||
* does the work before upload:
|
||||
* - JPEG only. Phone photos are usually HEIC, which the terminal rejects outright.
|
||||
* - Small. The ~3MB figure in the Hikvision docs is the ISAPI transport ceiling; the
|
||||
* face *library* caps far lower, so an oversized photo uploads happily and then fails
|
||||
* enrolment -- the worst failure mode, because it looks like it worked.
|
||||
* - Portrait. Terminals expect a 3:4 face frame; a square crop tends to cut the chin.
|
||||
*
|
||||
* Canvas does all of this, so there is no image dependency in this project.
|
||||
*/
|
||||
|
||||
/** Target frame. 3:4 portrait is what the face library expects. */
|
||||
export const FACE_WIDTH = 480
|
||||
export const FACE_HEIGHT = 640
|
||||
|
||||
/** Face-library budget, well under the ISAPI transport limit. */
|
||||
export const FACE_TARGET_BYTES = 150 * 1024
|
||||
export const FACE_MAX_BYTES = 200 * 1024
|
||||
|
||||
/** Guard on the *input* file, applied before decoding so a huge file can't exhaust memory. */
|
||||
export const INPUT_MAX_BYTES = 3 * 1024 * 1024
|
||||
|
||||
/** Quality ladder: start high, step down only while over budget. */
|
||||
const QUALITY_STEPS = [0.85, 0.75, 0.65]
|
||||
|
||||
export interface NormalizedFacePhoto {
|
||||
/** Bare base64 JPEG, no data: prefix — what the API expects. */
|
||||
base64: string
|
||||
/** data: URL for previewing in an <img>. */
|
||||
previewUrl: string
|
||||
bytes: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export class FacePhotoError extends Error {}
|
||||
|
||||
/**
|
||||
* Decodes any browser-readable image, cover-crops it to 3:4, resizes to 480x640 and
|
||||
* encodes JPEG under the face-library budget.
|
||||
*/
|
||||
export async function normalizeFacePhoto(
|
||||
source: Blob | HTMLVideoElement
|
||||
): Promise<NormalizedFacePhoto> {
|
||||
if (source instanceof Blob) {
|
||||
if (source.size > INPUT_MAX_BYTES) {
|
||||
throw new FacePhotoError(
|
||||
`That image is ${Math.round(source.size / 1024 / 1024)} MB. Choose one under 3 MB.`
|
||||
)
|
||||
}
|
||||
if (source.size === 0) {
|
||||
throw new FacePhotoError("That file is empty.")
|
||||
}
|
||||
}
|
||||
|
||||
const bitmap = await toBitmap(source)
|
||||
|
||||
try {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = FACE_WIDTH
|
||||
canvas.height = FACE_HEIGHT
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) throw new FacePhotoError("This browser cannot process images.")
|
||||
|
||||
// Cover-crop: fill the 3:4 frame from the centre rather than squashing the face.
|
||||
const scale = Math.max(FACE_WIDTH / bitmap.width, FACE_HEIGHT / bitmap.height)
|
||||
const drawWidth = bitmap.width * scale
|
||||
const drawHeight = bitmap.height * scale
|
||||
|
||||
ctx.imageSmoothingQuality = "high"
|
||||
// A face library needs an opaque background; a transparent PNG would otherwise
|
||||
// flatten to black and wreck the exposure.
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.fillRect(0, 0, FACE_WIDTH, FACE_HEIGHT)
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
(FACE_WIDTH - drawWidth) / 2,
|
||||
(FACE_HEIGHT - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight
|
||||
)
|
||||
|
||||
let blob: Blob | null = null
|
||||
for (const quality of QUALITY_STEPS) {
|
||||
blob = await canvasToBlob(canvas, quality)
|
||||
if (blob && blob.size <= FACE_TARGET_BYTES) break
|
||||
}
|
||||
|
||||
if (!blob) throw new FacePhotoError("Could not process that image. Try another photo.")
|
||||
|
||||
if (blob.size > FACE_MAX_BYTES) {
|
||||
throw new FacePhotoError(
|
||||
"Could not compress that photo enough for the access terminal. Try a simpler " +
|
||||
"background or better lighting."
|
||||
)
|
||||
}
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob)
|
||||
return {
|
||||
base64: dataUrl.slice(dataUrl.indexOf(",") + 1),
|
||||
previewUrl: dataUrl,
|
||||
bytes: blob.size,
|
||||
width: FACE_WIDTH,
|
||||
height: FACE_HEIGHT,
|
||||
}
|
||||
} finally {
|
||||
if ("close" in bitmap && typeof bitmap.close === "function") bitmap.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function toBitmap(source: Blob | HTMLVideoElement): Promise<ImageBitmap> {
|
||||
try {
|
||||
// createImageBitmap decodes HEIC on Safari and every other format the browser knows.
|
||||
return await createImageBitmap(source as ImageBitmapSource)
|
||||
} catch {
|
||||
throw new FacePhotoError(
|
||||
"That file isn't a readable image. Use a JPEG or PNG photo, or take one with the camera."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality))
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new FacePhotoError("Could not read the processed image."))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const DISPLAY_LOCALE = "en-LK"
|
||||
const DISPLAY_TIME_ZONE = "Asia/Colombo"
|
||||
|
||||
/** Formatting is explicit so SSR and browser renders produce the same output. */
|
||||
export function formatCurrency(amount: number) {
|
||||
return `Rs. ${amount.toLocaleString(DISPLAY_LOCALE)}`
|
||||
}
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString(DISPLAY_LOCALE, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: DISPLAY_TIME_ZONE,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString(DISPLAY_LOCALE, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: DISPLAY_TIME_ZONE,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string) {
|
||||
return `${formatDate(iso)}, ${formatTime(iso)}`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Best-effort geocoding via OpenStreetMap's free Nominatim API (no key required). */
|
||||
|
||||
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { display_name?: string }
|
||||
return data.display_name ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardGeocode(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
const [first] = await searchAddress(address, 1)
|
||||
return first ? { lat: first.lat, lng: first.lng } : null
|
||||
}
|
||||
|
||||
export interface AddressSuggestion {
|
||||
label: string
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
/** Address-autocomplete suggestions as the user types. */
|
||||
async function searchAddress(query: string, limit = 5): Promise<AddressSuggestion[]> {
|
||||
const q = query.trim()
|
||||
if (q.length < 3) return []
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=${limit}&q=${encodeURIComponent(q)}`
|
||||
)
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { display_name: string; lat: string; lon: string }[]
|
||||
return data
|
||||
.map((d) => ({ label: d.display_name, lat: Number(d.lat), lng: Number(d.lon) }))
|
||||
.filter((d) => !Number.isNaN(d.lat) && !Number.isNaN(d.lng))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export { reverseGeocode, forwardGeocode, searchAddress }
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Mirrors the tenant-scoped response from GET client/access-devices. */
|
||||
export interface AccessDevice {
|
||||
deviceId: string
|
||||
deviceName: string
|
||||
isActive: boolean
|
||||
lastSeenAt?: string | null
|
||||
isOnline: boolean
|
||||
presenceExpiresAt?: string | null
|
||||
isClaimed: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AccessAgent {
|
||||
agentId: string
|
||||
name: string
|
||||
isActive: boolean
|
||||
lastSeenAt?: string | null
|
||||
isOnline: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ClientAccessDevices {
|
||||
agents: AccessAgent[]
|
||||
devices: AccessDevice[]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ApiResponse<T> {
|
||||
statusCode: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Attendance entity rows from GET client/attendance. */
|
||||
export interface AttendanceRecord {
|
||||
attendanceId: string
|
||||
fkCustomerId?: string
|
||||
fkUserId?: string
|
||||
fkAccessProfileId?: number
|
||||
deviceUserId: number
|
||||
eventTime: string
|
||||
eventType: string
|
||||
customer?: { cusId: string; cusName: string } | null
|
||||
user?: { userId: string; userName?: string | null } | null
|
||||
accessProfile?: {
|
||||
accessProfileId: number
|
||||
displayName: string
|
||||
personType: string
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Mirrors TodayAttendanceDTO. */
|
||||
export interface TodayAttendance {
|
||||
customerName: string
|
||||
eventTime: string
|
||||
eventType?: string | null
|
||||
}
|
||||
|
||||
/** Mirrors AttendanceCountDTO. */
|
||||
export interface AttendanceCount {
|
||||
customerId: string
|
||||
customerName: string
|
||||
attendanceCount: number
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface LoginRequest {
|
||||
identifier: string
|
||||
password: string
|
||||
userTypeId?: string
|
||||
deviceName?: string
|
||||
rememberMe?: boolean
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Mirrors BookingDto. */
|
||||
export interface Booking {
|
||||
bookingId: string
|
||||
facilitySessionId: string
|
||||
facilityName?: string | null
|
||||
sessionStartTime?: string | null
|
||||
sessionEndTime?: string | null
|
||||
|
||||
isGuest: boolean
|
||||
customerId?: string | null
|
||||
guestName?: string | null
|
||||
guestPhone?: string | null
|
||||
|
||||
/** Only populated right after booking creation — the guest's receipt. */
|
||||
otpCode?: string | null
|
||||
otpVerified: boolean
|
||||
|
||||
paymentStatus: string
|
||||
amountCharged: number
|
||||
status: string
|
||||
bookedAt: string
|
||||
checkedInAt?: string | null
|
||||
|
||||
/** Public, unauthenticated link to this booking's confirmation page. */
|
||||
shareUrl?: string | null
|
||||
}
|
||||
|
||||
export interface CreateGuestBookingInput {
|
||||
facilitySessionId: string
|
||||
guestName: string
|
||||
guestPhone: string
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { GymPlan } from "@/lib/models/gym-plan"
|
||||
import type { PaymentStatus, SubscriptionSummary } from "@/lib/models/subscription"
|
||||
|
||||
/** One weekly weigh-in (CusWeightLossProgress). */
|
||||
export interface WeightProgressPoint {
|
||||
weeklyProgressId: string
|
||||
weight?: number | null
|
||||
recordedAt: string
|
||||
}
|
||||
|
||||
/** A member's goal/measurements (CustomerTarget) with their weight history. */
|
||||
export interface CustomerTarget {
|
||||
customerTargetId: string
|
||||
currentWeight?: number | null
|
||||
currentHeight?: number | null
|
||||
targetWeight?: number | null
|
||||
goal?: string | null
|
||||
timePeriod?: string | null
|
||||
weeklyProgresses?: WeightProgressPoint[] | null
|
||||
}
|
||||
|
||||
/** Customer entity as serialized by the backend (camelCase of the EF model). */
|
||||
export interface MemberProfile {
|
||||
cusId: string
|
||||
cusName?: string | null
|
||||
gender?: string | null
|
||||
/** Date of birth, "yyyy-MM-dd". Backend property is BoD. */
|
||||
boD?: string | null
|
||||
primaryPH?: string | null
|
||||
secondaryPH?: string | null
|
||||
email?: string | null
|
||||
nic?: string | null
|
||||
address?: string | null
|
||||
fkUserId?: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
isLocked?: boolean
|
||||
lockReason?: string | null
|
||||
lockedAt?: string | null
|
||||
customerTargets?: CustomerTarget[] | null
|
||||
user?: {
|
||||
userId: string
|
||||
userName?: string | null
|
||||
email?: string | null
|
||||
phoneNumber?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** A workout/meal plan the member is assigned to. */
|
||||
export interface FollowedPlan {
|
||||
planId: string
|
||||
name?: string | null
|
||||
isFollowing: boolean
|
||||
completedDate?: string | null
|
||||
}
|
||||
|
||||
/** GET client/customers/{id}/full — the "More Info" aggregate. */
|
||||
export interface MemberFullDetail {
|
||||
customer: MemberProfile
|
||||
subscriptions?: SubscriptionSummary[]
|
||||
workoutPlans: FollowedPlan[]
|
||||
mealPlans: FollowedPlan[]
|
||||
}
|
||||
|
||||
/** POST client/customers/{id}/medical (password-gated). */
|
||||
export interface MedicalDetails {
|
||||
bloodGroup?: string | null
|
||||
disabilities?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Row shape of GET client/customers (MemberListRowDto): one row per member.
|
||||
* Group members each get a row pointing at their shared subscription.
|
||||
*/
|
||||
export interface MemberSubscription {
|
||||
subscriptionId: string
|
||||
fkCustomerPlanId: string
|
||||
fkCustomerId: string
|
||||
onboardDate: string
|
||||
/** Frozen at creation, never changes again -- use renewalDate for current status/listing. */
|
||||
expiryDate?: string | null
|
||||
renewalDate?: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
customer?: MemberProfile | null
|
||||
isPrimary: boolean
|
||||
/** False when unassigned from the plan but still a member of the gym — re-assignable. */
|
||||
isPlanAssigned?: boolean
|
||||
planName?: string | null
|
||||
/** "None" means this member was created without a plan yet — assign one to make them active. */
|
||||
planScope?: "Individual" | "Group" | "None"
|
||||
memberCount?: number
|
||||
memberLimit?: number
|
||||
invoiceAmount?: number
|
||||
paidAmount?: number
|
||||
balance?: number
|
||||
paymentStatus?: PaymentStatus
|
||||
allowSplitPayments?: boolean
|
||||
fingerprintId?: number | null
|
||||
fingerprintName?: string | null
|
||||
accessProfileId?: number | null
|
||||
accessNumber?: string | null
|
||||
accessProvisionStatus?: "NotConfigured" | "NoDevice" | "Pending" | "Synced" | "Failed"
|
||||
actualAccessState?: "Unknown" | "Enabled" | "Disabled" | "Mixed"
|
||||
desiredAccessState?: "Enabled" | "Disabled"
|
||||
fingerprintCount?: number
|
||||
accessLastError?: string | null
|
||||
isLocked?: boolean
|
||||
lockReason?: string | null
|
||||
/** Face-photo enrolment on the gym's access terminals. */
|
||||
hasFacePhoto?: boolean
|
||||
facePhotoStatus?: "None" | "Pending" | "Synced" | "Failed"
|
||||
facePhotoError?: string | null
|
||||
}
|
||||
|
||||
/** GET client/customers/unlinked-fingerprints — a device enrollment not yet linked to a Customer. */
|
||||
export interface UnlinkedFingerprint {
|
||||
id: string
|
||||
deviceUserId: number
|
||||
deviceEmployeeNo: string
|
||||
deviceReportedName?: string | null
|
||||
deviceUserType?: string | null
|
||||
fingerprintCount: number
|
||||
deviceId: string
|
||||
deviceName?: string | null
|
||||
discoveredAt: string
|
||||
}
|
||||
|
||||
export interface AccessCommandStatus {
|
||||
accessProfileId: number
|
||||
accessNumber: string
|
||||
personType: "Customer" | "Staff"
|
||||
desiredAccessState: "Enabled" | "Disabled"
|
||||
provisionStatus: "NoDevice" | "Pending" | "Synced" | "Failed"
|
||||
actualAccessState: "Unknown" | "Enabled" | "Disabled" | "Mixed"
|
||||
fingerprintCount: number
|
||||
lastError?: string | null
|
||||
lastSyncedAt?: string | null
|
||||
deviceCount: number
|
||||
syncedDeviceCount: number
|
||||
}
|
||||
|
||||
/** GET client/customers/{id} response. */
|
||||
export interface MemberDetail {
|
||||
customer: MemberProfile
|
||||
plans: GymPlan[]
|
||||
subscriptions?: SubscriptionSummary[]
|
||||
}
|
||||
|
||||
export interface MemberInput {
|
||||
fullname: string
|
||||
userName: string
|
||||
email: string
|
||||
mobileNumber: string
|
||||
gender: string
|
||||
/** "yyyy-MM-dd" */
|
||||
birthday: string
|
||||
nic?: string
|
||||
customerPlanId?: string
|
||||
workoutPlanIds?: string[]
|
||||
mealPlanIds?: string[]
|
||||
/** ISO datetime. Only meaningful alongside customerPlanId. */
|
||||
onboardDate?: string
|
||||
/** false bills the plan at Rs. 0.00 and skips the automatic payment link. */
|
||||
chargePayment?: boolean
|
||||
discountType?: "Percentage" | "Flat"
|
||||
discountValue?: number
|
||||
/** Set true only on the resubmit after staff has confirmed linking an existing platform
|
||||
* customer to this gym (see CreateMemberResult.requiresConfirmation). */
|
||||
confirmLink?: boolean
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type FacilityType = "Gym" | "Pool" | "Court" | "Class"
|
||||
|
||||
/** Mirrors FacilityDto. */
|
||||
export interface Facility {
|
||||
facilityId: string
|
||||
name: string
|
||||
facilityType: FacilityType
|
||||
description?: string | null
|
||||
defaultCapacity: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface FacilityInput {
|
||||
name: string
|
||||
facilityType: FacilityType
|
||||
description?: string
|
||||
defaultCapacity: number
|
||||
}
|
||||
|
||||
/** Mirrors FacilitySessionDto. */
|
||||
export interface FacilitySession {
|
||||
facilitySessionId: string
|
||||
facilityId: string
|
||||
facilityName?: string | null
|
||||
startTime: string
|
||||
endTime: string
|
||||
capacity: number
|
||||
bookedCount: number
|
||||
isCancelled: boolean
|
||||
}
|
||||
|
||||
export interface FacilitySessionInput {
|
||||
facilityId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
/** Overrides the facility's default capacity for this specific session. */
|
||||
capacity?: number
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Item shape returned by GET client/feed (ShowFeedByClient). */
|
||||
export interface FeedPost {
|
||||
feedPostId: string
|
||||
caption?: string | null
|
||||
description?: string | null
|
||||
userId: string
|
||||
docId?: number | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
likeCount: number
|
||||
document?: {
|
||||
fileName?: string | null
|
||||
fileType?: string | null
|
||||
documentUrl?: string | null
|
||||
documentType?: string | null
|
||||
/** Base64 file content. */
|
||||
data?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Inner wrapper FeedService puts around the list. */
|
||||
export interface FeedListResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data: FeedPost[]
|
||||
}
|
||||
|
||||
export interface FeedDocumentInput {
|
||||
FileName: string
|
||||
/** Base64 file content (DocumentService reads the `Base64` key). FeedService injects FileType. */
|
||||
Base64: string
|
||||
}
|
||||
|
||||
export interface CreateFeedInput {
|
||||
caption?: string
|
||||
description?: string
|
||||
document: FeedDocumentInput[]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type PlanScope = "Individual" | "Group"
|
||||
export type DurationMode = "Rolling" | "FixedRange"
|
||||
|
||||
/** Mirrors CustomerPlanDto (gym membership plans). */
|
||||
export interface GymPlan {
|
||||
customerPlanId: string
|
||||
fkClientId: string
|
||||
clientName?: string | null
|
||||
planName: string
|
||||
planDescription?: string | null
|
||||
planPrice: number
|
||||
hasOnboardingFee: boolean
|
||||
onboardingFeeAmount: number
|
||||
durationInDays: number
|
||||
planScope: PlanScope
|
||||
memberLimit: number
|
||||
durationMode: DurationMode
|
||||
validFrom?: string | null
|
||||
validTo?: string | null
|
||||
allowSplitPayments: boolean
|
||||
installmentCount: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
/** Places this plan grants access to. Empty = unrestricted (every active device). */
|
||||
placeIds: string[]
|
||||
placeNames: string[]
|
||||
}
|
||||
|
||||
export interface GymPlanInput {
|
||||
planName: string
|
||||
planDescription?: string
|
||||
planPrice: number
|
||||
hasOnboardingFee?: boolean
|
||||
onboardingFeeAmount?: number
|
||||
/** Required for Rolling plans; omit for FixedRange. */
|
||||
durationInDays?: number
|
||||
planScope?: PlanScope
|
||||
/** Required (>= 2) for Group plans. */
|
||||
memberLimit?: number
|
||||
durationMode?: DurationMode
|
||||
/** ISO dates; required for FixedRange plans. */
|
||||
validFrom?: string
|
||||
validTo?: string
|
||||
allowSplitPayments?: boolean
|
||||
/** Number of mostly-equal installments when split payments are enabled. */
|
||||
installmentCount?: number
|
||||
/** Replaces the plan's place set when provided; omit to leave unchanged, [] to clear. */
|
||||
placeIds?: string[]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Mirrors GymRoleDto. */
|
||||
export interface GymRole {
|
||||
gymRoleId: string
|
||||
name: string
|
||||
isActive: boolean
|
||||
navKeys: string[]
|
||||
}
|
||||
|
||||
export interface CreateGymRoleInput {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface UpdateGymRoleInput {
|
||||
name?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface SetRolePermissionsInput {
|
||||
navKeys: string[]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MemberProfile } from "@/lib/models/customer"
|
||||
import type { GymPlan } from "@/lib/models/gym-plan"
|
||||
|
||||
/** CustomerInvoice entity from GET client/member-invoices (member billing). */
|
||||
export interface MemberInvoice {
|
||||
customerInvoiceId: string
|
||||
fkCustomerSubscriptionId: string
|
||||
fkClientId: string
|
||||
amount: number
|
||||
/** The portion of `amount` carried forward from a prior cycle's unpaid balance (renewal
|
||||
* approval flow), rather than this cycle's own charge. 0/undefined when there's none --
|
||||
* only populated by the renewal-approvals list and the single-invoice detail endpoint. */
|
||||
carriedForwardAmount?: number
|
||||
status: string
|
||||
/** Approval gate for the renewal-approval workflow: false means this invoice was
|
||||
* auto-generated by the daily renewal sweep and is awaiting approve/change-plan/reject.
|
||||
* Independent of `status`. */
|
||||
isActive: boolean
|
||||
paidAmount?: number
|
||||
balance?: number
|
||||
customerInvoiceDate: string
|
||||
/** Flattened from CustomerSubscription.TenantCustomerLink -- use this for display, not
|
||||
* customerSubscription.customer, which the backend never actually populates. */
|
||||
customerName?: string | null
|
||||
customerPhone?: string | null
|
||||
/** TenantCustomerLink id -- used for "view previous invoices" and latest-per-customer grouping. */
|
||||
fkCustomerId?: string | null
|
||||
customerSubscription?: {
|
||||
subscriptionId: string
|
||||
onboardDate: string
|
||||
expiryDate?: string | null
|
||||
renewalDate?: string | null
|
||||
customer?: MemberProfile | null
|
||||
customerPlan?: GymPlan | null
|
||||
} | null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type LedgerEntryType = "Income" | "Expense"
|
||||
|
||||
/** Mirrors LedgerEntryDto — one row in the P&L breakdown (derived or manual). */
|
||||
export interface LedgerEntry {
|
||||
id?: string | null
|
||||
entryType: LedgerEntryType
|
||||
category: string
|
||||
description?: string | null
|
||||
amount: number
|
||||
entryDate: string
|
||||
isManual: boolean
|
||||
}
|
||||
|
||||
export interface CategoryTotal {
|
||||
category: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
/** GET client/reports/profit-loss. */
|
||||
export interface ProfitLoss {
|
||||
from: string
|
||||
to: string
|
||||
totalIncome: number
|
||||
totalExpense: number
|
||||
netProfit: number
|
||||
incomeByCategory: CategoryTotal[]
|
||||
expenseByCategory: CategoryTotal[]
|
||||
entries: LedgerEntry[]
|
||||
}
|
||||
|
||||
export interface AddLedgerEntryInput {
|
||||
entryType: LedgerEntryType
|
||||
category: string
|
||||
description?: string
|
||||
amount: number
|
||||
entryDate?: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface LockPolicy {
|
||||
lockAfterDaysOverdue: number | null
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface NavItemApi {
|
||||
key: string
|
||||
label: string
|
||||
shortLabel?: string
|
||||
href: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface NavResponse {
|
||||
isOwner: boolean
|
||||
items: NavItemApi[]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** GET client/pay/{token} — public pay-by-link invoice summary. */
|
||||
export interface PayByLinkInvoice {
|
||||
memberName?: string | null
|
||||
gymName?: string | null
|
||||
planName?: string | null
|
||||
amount: number
|
||||
balance: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface PayByLinkResult {
|
||||
success: boolean
|
||||
invoiceId: string
|
||||
paidAmount: number
|
||||
balance: number
|
||||
status: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Mirrors PlaceDto. A physical access zone (e.g. "Pool Area", "Indoor Gym") with one
|
||||
* fingerprint/face terminal for now. */
|
||||
export interface Place {
|
||||
placeId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
deviceId?: string | null
|
||||
deviceName?: string | null
|
||||
}
|
||||
|
||||
export interface PlaceInput {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Shared shapes for workout & meal plans (mirrors WorkoutPlanDto / MealPlanDto). */
|
||||
|
||||
export interface AssignedCustomer {
|
||||
/** customerWorkoutId or customerMealId — the id UnassignWorkout/UnassignMeal expects. */
|
||||
assignmentId: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
isActive: boolean
|
||||
assignedDate: string
|
||||
}
|
||||
|
||||
export interface TrainingPlan {
|
||||
/** workoutPlanId or mealPlanId, normalized to `planId` at the API layer. */
|
||||
planId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
isTemplate: boolean
|
||||
categoryId: string
|
||||
categoryName?: string | null
|
||||
clientId?: string | null
|
||||
clientName?: string | null
|
||||
isActive: boolean
|
||||
createDate: string
|
||||
assignedCustomers: AssignedCustomer[]
|
||||
}
|
||||
|
||||
export interface PlanCategory {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface PlanItemRow {
|
||||
/** Day label for workouts (e.g. "Monday"); free text for meals ("Breakfast"). */
|
||||
label: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/** One structured exercise within a workout day (mirrors WorkoutExerciseDto). */
|
||||
export interface WorkoutExerciseView {
|
||||
id: string
|
||||
name: string
|
||||
muscleGroup?: string | null
|
||||
sets?: number | null
|
||||
reps?: string | null
|
||||
description?: string | null
|
||||
breathIn?: string | null
|
||||
breathOut?: string | null
|
||||
videoUrl?: string | null
|
||||
}
|
||||
|
||||
/** A saved plan item, normalized for the detail panel (one card per item). */
|
||||
export interface PlanItemView {
|
||||
id: string
|
||||
/** exerciseDay (workout) or meal name (meal). */
|
||||
title: string
|
||||
/** exercises list (workout) or description (meal). */
|
||||
detail: string
|
||||
/** Structured exercises (workout only) — empty for meal items. */
|
||||
exercises: WorkoutExerciseView[]
|
||||
}
|
||||
|
||||
export interface CreatePlanInput {
|
||||
name: string
|
||||
description?: string
|
||||
categoryId: string
|
||||
isTemplate?: boolean
|
||||
customerIds?: string[]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** One equipment type + how many the gym has (e.g. Treadmill × 10). */
|
||||
export interface GymEquipment {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/** Gym business record attached to a client (from GET client/me). */
|
||||
export interface ClientBusiness {
|
||||
bid: string
|
||||
businessName?: string | null
|
||||
address?: string | null
|
||||
city?: string | null
|
||||
description?: string | null
|
||||
businessLogoId?: string | null
|
||||
/** JSON blob: { hasAc, equipment: [{ name, count }] } — parse with parseFacilities(). */
|
||||
facilities?: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
/** Mirrors the Client entity returned by GET client/me. */
|
||||
export interface ClientProfile {
|
||||
clientId: string
|
||||
clientName?: string | null
|
||||
clientPhone?: string | null
|
||||
clientEmail?: string | null
|
||||
clientBusinesses?: ClientBusiness[] | null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Mirrors StaffDto. */
|
||||
export interface StaffMember {
|
||||
staffId: string
|
||||
userId: string
|
||||
userName?: string | null
|
||||
email?: string | null
|
||||
phoneNumber?: string | null
|
||||
clientId: string
|
||||
clientName?: string | null
|
||||
gymRoleId?: string | null
|
||||
gymRoleName?: string | null
|
||||
staffPosition?: string | null
|
||||
department?: string | null
|
||||
hireDate: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
fingerprintId?: number | null
|
||||
fingerprintName?: string | null
|
||||
accessProfileId?: number | null
|
||||
accessNumber?: string | null
|
||||
accessProvisionStatus?: "NotConfigured" | "NoDevice" | "Pending" | "Synced" | "Failed"
|
||||
actualAccessState?: "Unknown" | "Enabled" | "Disabled" | "Mixed"
|
||||
desiredAccessState?: "Enabled" | "Disabled"
|
||||
fingerprintCount?: number
|
||||
accessLastError?: string | null
|
||||
}
|
||||
|
||||
export interface StaffInput {
|
||||
userName: string
|
||||
email: string
|
||||
phoneNumber: string
|
||||
staffPosition?: string
|
||||
department?: string
|
||||
/** "yyyy-MM-dd" — create only. */
|
||||
hireDate?: string
|
||||
/** Create only. Left unset, the new staff member has no dashboard access until a role
|
||||
* is assigned later via Roles & Permissions. */
|
||||
gymRoleId?: string
|
||||
}
|
||||
|
||||
export type StaffRateType = "Monthly" | "Hourly"
|
||||
|
||||
/** Mirrors StaffPayRateDto. */
|
||||
export interface StaffPayRate {
|
||||
id: string
|
||||
rateType: StaffRateType
|
||||
amount: number
|
||||
effectiveFrom: string
|
||||
}
|
||||
|
||||
export interface UpsertStaffPayRateInput {
|
||||
rateType: StaffRateType
|
||||
amount: number
|
||||
}
|
||||
|
||||
/** Mirrors StaffPaymentDto. */
|
||||
export interface StaffPayment {
|
||||
id: string
|
||||
amount: number
|
||||
note?: string | null
|
||||
paidAt: string
|
||||
}
|
||||
|
||||
export interface RecordStaffPaymentInput {
|
||||
amount: number
|
||||
note?: string
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Mirrors ClientStatSummaryDto (camelCased by the backend). */
|
||||
export interface ClientStatSummary {
|
||||
totalCustomerCount: number
|
||||
thisMonthCustomerCount: number
|
||||
lastMonthCustomerCount: number
|
||||
customerGrowthPercentage: number
|
||||
/** These four are zeroed server-side for non-owner callers — revenue is owner-only. */
|
||||
thisMonthRevenue: number
|
||||
lastMonthRevenue: number
|
||||
revenueGrowthPercentage: number
|
||||
totalRevenue: number
|
||||
overdueCount: number
|
||||
expiringWithin7DaysCount: number
|
||||
/** Always "today" (UTC date), independent of any reporting-period filter elsewhere on this
|
||||
* object -- visible to every staff member, not owner-gated like revenue above. */
|
||||
todayPaymentsCount: number
|
||||
todayPaymentsTotal: number
|
||||
/** Muscle Beast-only pilot -- gated server-side, so the UI never needs to know any gym's
|
||||
* ClientId to decide whether to show the Today's Payments card/filter. */
|
||||
showTodayPayments: boolean
|
||||
}
|
||||
|
||||
/** Mirrors MonthlyRevenueStat. */
|
||||
export interface MonthlyRevenueStat {
|
||||
year: number
|
||||
month: number
|
||||
monthLabel: string
|
||||
/** Zeroed server-side for non-owner callers — revenue is owner-only. */
|
||||
revenue: number
|
||||
newMemberCount: number
|
||||
}
|
||||
|
||||
interface CustomerRef {
|
||||
cusId: string
|
||||
cusName: string
|
||||
}
|
||||
|
||||
/** PaymentTransaction entity rows from GET client/stats/recent-payments. */
|
||||
export interface RecentPayment {
|
||||
attemptId: string
|
||||
payDate: string | null
|
||||
payStatus: string
|
||||
payAmount: number
|
||||
payNote?: string | null
|
||||
customer?: CustomerRef | null
|
||||
}
|
||||
|
||||
/** Attendance entity rows from GET client/stats/recent-attendance. */
|
||||
export interface RecentAttendance {
|
||||
attendanceId: string
|
||||
eventTime: string
|
||||
eventType: string
|
||||
deviceUserId: number
|
||||
customer?: CustomerRef | null
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
export type PaymentStatus = "Pending" | "Partial" | "Paid"
|
||||
|
||||
export interface SubscriptionMemberInfo {
|
||||
subscriptionMemberId: string
|
||||
customerId: string
|
||||
cusName?: string | null
|
||||
email?: string | null
|
||||
primaryPH?: string | null
|
||||
isPrimary: boolean
|
||||
joinedAt: string
|
||||
isActive: boolean
|
||||
/** False when unassigned from the plan but still a member of the gym. */
|
||||
isPlanAssigned?: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionInstallment {
|
||||
subscriptionInstallmentId: string
|
||||
installmentNo: number
|
||||
dueDate: string
|
||||
amount: number
|
||||
paidAmount: number
|
||||
status: PaymentStatus
|
||||
}
|
||||
|
||||
export interface AutoChargeCard {
|
||||
cardId: string
|
||||
cardLast4?: string | null
|
||||
cardType?: string | null
|
||||
}
|
||||
|
||||
export interface MemberCard {
|
||||
jobId: string
|
||||
cardId?: string | null
|
||||
cardNumber?: string | null
|
||||
cardType?: string | null
|
||||
nickName?: string | null
|
||||
isPrimary: boolean
|
||||
consentGiven: boolean
|
||||
}
|
||||
|
||||
/** GET client/subscriptions/{id}. */
|
||||
export interface SubscriptionSummary {
|
||||
subscriptionId: string
|
||||
customerPlanId: string
|
||||
planName?: string | null
|
||||
planScope: "Individual" | "Group"
|
||||
memberLimit: number
|
||||
durationMode: "Rolling" | "FixedRange"
|
||||
allowSplitPayments: boolean
|
||||
planPrice: number
|
||||
onboardDate: string
|
||||
/** Frozen at creation, never changes again -- see renewalDate for the current cycle end. */
|
||||
expiryDate?: string | null
|
||||
renewalDate?: string | null
|
||||
isActive: boolean
|
||||
customerInvoiceId?: string | null
|
||||
invoiceAmount: number
|
||||
paidAmount: number
|
||||
balance: number
|
||||
paymentStatus: PaymentStatus
|
||||
members: SubscriptionMemberInfo[]
|
||||
installments: SubscriptionInstallment[]
|
||||
autoChargeEnabled: boolean
|
||||
autoChargeCard?: AutoChargeCard | null
|
||||
}
|
||||
|
||||
export interface SubscriptionPayment {
|
||||
attemptId: string
|
||||
payDate?: string | null
|
||||
payStatus: string
|
||||
payAmount: number
|
||||
payNote?: string | null
|
||||
refType?: string | null
|
||||
paymentMethod?: "Cash" | "Bank" | null
|
||||
bankRefNo?: string | null
|
||||
isApproved?: boolean | null
|
||||
}
|
||||
|
||||
/** GET client/subscriptions/{id}/payments. */
|
||||
export interface SubscriptionPaymentsResult {
|
||||
invoiceId?: string | null
|
||||
invoiceAmount: number
|
||||
paidAmount: number
|
||||
balance: number
|
||||
status: PaymentStatus
|
||||
installments: SubscriptionInstallment[]
|
||||
payments: SubscriptionPayment[]
|
||||
}
|
||||
|
||||
export interface RecordPaymentInput {
|
||||
amount: number
|
||||
note?: string
|
||||
paymentMethod: "Cash" | "Bank"
|
||||
/** Reference/slip number — only meaningful (and sent) when paymentMethod is "Bank". */
|
||||
bankRefNo?: string
|
||||
}
|
||||
|
||||
export type ExtraFeeFrequency = "OneTime" | "Recurring"
|
||||
|
||||
/** Mirrors CustomerSubItemDto — an optional extra fee on a subscription (e.g. coaching fee). */
|
||||
export interface SubscriptionExtraFee {
|
||||
customerSubItemId: string
|
||||
subItemName?: string | null
|
||||
subItemDescription?: string | null
|
||||
subItemPrice: number
|
||||
frequency: ExtraFeeFrequency
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AddExtraFeeInput {
|
||||
name: string
|
||||
description?: string
|
||||
amount: number
|
||||
frequency: ExtraFeeFrequency
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** TicketStatusEnum: 1=Pending, 2=Open, 3=Closed (serialized as int). */
|
||||
export type TicketStatus = 1 | 2 | 3
|
||||
|
||||
export const TICKET_STATUS_LABEL: Record<TicketStatus, string> = {
|
||||
1: "Pending",
|
||||
2: "Open",
|
||||
3: "Closed",
|
||||
}
|
||||
|
||||
/** Mirrors TicketDto (list rows). */
|
||||
export interface Ticket {
|
||||
ticketId: string
|
||||
mainReason: string
|
||||
createDate: string
|
||||
userId: string
|
||||
userName?: string | null
|
||||
userEmail?: string | null
|
||||
adminId?: string | null
|
||||
adminName?: string | null
|
||||
ticketCategoryId?: string | null
|
||||
ticketCategoryName?: string | null
|
||||
userRating?: number | null
|
||||
ticketStatus: TicketStatus
|
||||
}
|
||||
|
||||
/** Mirrors TicketMessageDto. */
|
||||
export interface TicketMessage {
|
||||
messageId: string
|
||||
isSentByAdmin: boolean
|
||||
message: string
|
||||
attachmentURL?: string | null
|
||||
createDate: string
|
||||
}
|
||||
|
||||
/** Mirrors TicketDetailDto. */
|
||||
export interface TicketDetail extends Ticket {
|
||||
userPhoneNumber?: string | null
|
||||
userFeedback?: string | null
|
||||
feedbackAt?: string | null
|
||||
messages: TicketMessage[]
|
||||
}
|
||||
|
||||
export interface TicketCategory {
|
||||
ticketCategoryId: string
|
||||
ticketCategoryName: string
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Mirrors TimeBankCardDto. */
|
||||
export interface TimeBankCard {
|
||||
timeBankCardId: string
|
||||
rfidTagId: string
|
||||
customerId?: string | null
|
||||
totalMinutesPurchased: number
|
||||
minutesRemaining: number
|
||||
amountPaid: number
|
||||
purchasedAt: string
|
||||
expiresAt?: string | null
|
||||
isActive: boolean
|
||||
|
||||
/** True right after a tap-in swipe (session open, clock running). */
|
||||
sessionOpen: boolean
|
||||
sessionStartedAt?: string | null
|
||||
sessionFacilityName?: string | null
|
||||
|
||||
/** Only populated in the response to a tap-out swipe. */
|
||||
minutesDeductedThisSwipe?: number | null
|
||||
}
|
||||
|
||||
export interface IssueTimeBankCardInput {
|
||||
rfidTagId: string
|
||||
totalMinutesPurchased: number
|
||||
amountPaid: number
|
||||
/** Ties the card to a member's own account; omit for a walk-in-only card. */
|
||||
customerId?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
export interface SwipeTimeBankCardInput {
|
||||
rfidTagId: string
|
||||
facilityId: string
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
CalendarCheck,
|
||||
CalendarClock,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
Receipt,
|
||||
Rss,
|
||||
Settings,
|
||||
TicketIcon,
|
||||
Users,
|
||||
UsersRound,
|
||||
Utensils,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface DashboardNavItem {
|
||||
key: string
|
||||
label: string
|
||||
/** Compact label used in the mobile bottom bar. */
|
||||
shortLabel?: string
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
// Real dashboard usage fetches this list from the backend (permission-filtered) via
|
||||
// useNavItems() — see hooks/use-nav-items.ts. NAV_ITEMS below is now sandbox-demo-only
|
||||
// (src/app/sandbox/[token]/page.tsx has no backend to call) and the icon-name → LucideIcon
|
||||
// mapping source for the live hook (see ICON_MAP there).
|
||||
export const NAV_ITEMS: DashboardNavItem[] = [
|
||||
{ key: "dashboard", label: "Dashboard", shortLabel: "Home", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ key: "attendance", label: "Attendance", href: "/dashboard/attendance", icon: CalendarCheck },
|
||||
{ key: "members", label: "Members", href: "/dashboard/members", icon: Users },
|
||||
{ key: "memberships", label: "Memberships", href: "/dashboard/memberships", icon: CreditCard },
|
||||
{ key: "customer-payments", label: "Customer Payments", shortLabel: "Payments", href: "/dashboard/customer-payments", icon: Receipt },
|
||||
{ key: "bookings", label: "Bookings", href: "/dashboard/bookings", icon: CalendarClock },
|
||||
{ key: "workout-plans", label: "Workout Plans", href: "/dashboard/workout-plans", icon: Activity },
|
||||
{ key: "meal-plans", label: "Meal Plans", href: "/dashboard/meal-plans", icon: Utensils },
|
||||
{ key: "staff", label: "Staff", href: "/dashboard/staff", icon: UsersRound },
|
||||
{ key: "feed", label: "Feed", href: "/dashboard/feed", icon: Rss },
|
||||
{ key: "tickets", label: "Tickets", href: "/dashboard/tickets", icon: TicketIcon },
|
||||
{ key: "reports", label: "Reports", href: "/dashboard/reports", icon: BarChart3 },
|
||||
{ key: "settings", label: "Gym Profile", href: "/dashboard/settings", icon: Settings },
|
||||
]
|
||||
|
||||
/** Sections pinned to the mobile bottom bar; the rest live behind "More". */
|
||||
export const MOBILE_PRIMARY_KEYS = ["dashboard", "members", "attendance", "customer-payments"]
|
||||
|
||||
export function isNavItemActive(item: DashboardNavItem, pathname: string) {
|
||||
if (item.href === "/dashboard") return pathname === item.href
|
||||
// Plain startsWith would also match "/dashboard/memberships" against "/dashboard/members"
|
||||
// (the latter is a literal string prefix of the former) — require a path-segment boundary.
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
HubConnection,
|
||||
HubConnectionBuilder,
|
||||
HubConnectionState,
|
||||
LogLevel,
|
||||
} from "@microsoft/signalr"
|
||||
|
||||
type AccessEventType =
|
||||
| "attendance"
|
||||
| "deviceInventory"
|
||||
| "accessState"
|
||||
| "devicePresence"
|
||||
|
||||
interface DevicePresenceUpdate {
|
||||
deviceId: string
|
||||
isOnline: boolean
|
||||
lastSeenAt?: string | null
|
||||
presenceExpiresAt?: string | null
|
||||
}
|
||||
|
||||
interface DevicePresencePayload {
|
||||
agentId: string
|
||||
devices: DevicePresenceUpdate[]
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
interface AccessRealtimeEvent {
|
||||
type: AccessEventType
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
type AccessEventListener = (event: AccessRealtimeEvent) => void
|
||||
|
||||
// Trimmed so a trailing slash on the env var can't produce a double-slash in the hub URL
|
||||
// below -- some routing layers 404 on that (e.g. "https://host.com//hubs/access").
|
||||
const CORE_API_URL = (
|
||||
process.env.NEXT_PUBLIC_CORE_API_URL ?? "https://hexafit.hexdive.com"
|
||||
).replace(/\/+$/, "")
|
||||
|
||||
const listeners = new Set<AccessEventListener>()
|
||||
let connection: HubConnection | null = null
|
||||
let startPromise: Promise<void> | null = null
|
||||
let initialRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function emit(type: AccessEventType, payload: unknown) {
|
||||
for (const listener of listeners) listener({ type, payload })
|
||||
}
|
||||
|
||||
function getConnection() {
|
||||
if (connection) return connection
|
||||
|
||||
connection = new HubConnectionBuilder()
|
||||
.withUrl(`${CORE_API_URL}/hubs/access`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
.withAutomaticReconnect([0, 2_000, 5_000, 10_000, 30_000])
|
||||
.configureLogging(LogLevel.Warning)
|
||||
.build()
|
||||
|
||||
connection.on("AttendanceUpdated", (payload) => emit("attendance", payload))
|
||||
connection.on("DeviceInventoryUpdated", (payload) =>
|
||||
emit("deviceInventory", payload)
|
||||
)
|
||||
connection.on("AccessStateUpdated", (payload) => emit("accessState", payload))
|
||||
connection.on("DevicePresenceUpdated", (payload) =>
|
||||
emit("devicePresence", payload)
|
||||
)
|
||||
connection.onclose(() => {
|
||||
startPromise = null
|
||||
scheduleInitialRetry()
|
||||
})
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
function scheduleInitialRetry() {
|
||||
if (listeners.size === 0 || initialRetryTimer) return
|
||||
initialRetryTimer = setTimeout(() => {
|
||||
initialRetryTimer = null
|
||||
void ensureStarted()
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
async function ensureStarted() {
|
||||
const hub = getConnection()
|
||||
if (
|
||||
hub.state === HubConnectionState.Connected ||
|
||||
hub.state === HubConnectionState.Connecting ||
|
||||
hub.state === HubConnectionState.Reconnecting
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (startPromise) return startPromise
|
||||
|
||||
startPromise = hub
|
||||
.start()
|
||||
.catch(() => {
|
||||
scheduleInitialRetry()
|
||||
})
|
||||
.finally(() => {
|
||||
startPromise = null
|
||||
})
|
||||
return startPromise
|
||||
}
|
||||
|
||||
function subscribeAccessEvents(listener: AccessEventListener) {
|
||||
listeners.add(listener)
|
||||
void ensureStarted()
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0 && initialRetryTimer) {
|
||||
clearTimeout(initialRetryTimer)
|
||||
initialRetryTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { subscribeAccessEvents }
|
||||
export type {
|
||||
AccessRealtimeEvent,
|
||||
AccessEventType,
|
||||
DevicePresencePayload,
|
||||
DevicePresenceUpdate,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ApiError } from "@/lib/api/core-http"
|
||||
|
||||
interface UseFetchResult<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
reload: () => void
|
||||
mutate: (updater: (data: T | null) => T | null) => void
|
||||
}
|
||||
|
||||
function useFetch<T>(fetcher: () => Promise<T>): UseFetchResult<T> {
|
||||
const fetcherRef = React.useRef(fetcher)
|
||||
const [data, setData] = React.useState<T | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [tick, setTick] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
fetcherRef.current = fetcher
|
||||
}, [fetcher])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void Promise.resolve().then(async () => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetcherRef.current()
|
||||
if (!cancelled) setData(result)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`
|
||||
return
|
||||
}
|
||||
const message = err instanceof ApiError ? err.message : "Something went wrong."
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [tick])
|
||||
|
||||
const reload = React.useCallback(() => setTick((t) => t + 1), [])
|
||||
const mutate = React.useCallback((updater: (data: T | null) => T | null) => {
|
||||
setData((prev) => updater(prev))
|
||||
}, [])
|
||||
return { data, loading, error, reload, mutate }
|
||||
}
|
||||
|
||||
export { useFetch }
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(query)
|
||||
const onChange = () => setMatches(mql.matches)
|
||||
onChange()
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
export { useMediaQuery }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* Runs `fn`, toasting the real error message on failure, then rethrows so the caller can
|
||||
* still run its own follow-up (e.g. keep a dialog open, revert optimistic state). Does
|
||||
* nothing extra on success — that (toast.success, closing a dialog, reload()) stays the
|
||||
* caller's job.
|
||||
*
|
||||
* Use this directly for per-row busy state (a table where any row can be in flight
|
||||
* independently); use `useMutation` below for the common single-busy-flag case.
|
||||
*/
|
||||
async function runMutation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Something went wrong.")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
interface UseMutationResult {
|
||||
busy: boolean
|
||||
run: <T>(fn: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
/** `runMutation`, plus tracking one `busy` flag — for a single submit button/dialog. */
|
||||
function useMutation(): UseMutationResult {
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
|
||||
const run = React.useCallback(async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
return await runMutation(fn)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { busy, run }
|
||||
}
|
||||
|
||||
export { useMutation, runMutation }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** Shared client-side validation + formatting helpers. */
|
||||
|
||||
/** Strip everything except digits (for phone input). */
|
||||
export function normalizePhone(raw: string) {
|
||||
return raw.replace(/\D/g, "")
|
||||
}
|
||||
|
||||
/** Sri Lankan mobile: 10 digits starting with 0 (e.g. 0771234567). Pass digits only. */
|
||||
export function isValidSriLankanMobile(digits: string) {
|
||||
return /^0\d{9}$/.test(digits)
|
||||
}
|
||||
|
||||
/** Sri Lankan NIC: old format 9 digits + V/X, or new format 12 digits. */
|
||||
export function isValidSriLankanNIC(nic: string) {
|
||||
const value = nic.trim()
|
||||
return /^\d{9}[vVxX]$/.test(value) || /^\d{12}$/.test(value)
|
||||
}
|
||||
|
||||
/** Email with a TLD of at least two characters. */
|
||||
export function isValidEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim())
|
||||
}
|
||||
|
||||
export const MAX_NIC_FILE_BYTES = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
export function isFileWithinSize(file: File, maxBytes = MAX_NIC_FILE_BYTES) {
|
||||
return file.size <= maxBytes
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** Mask a phone number, revealing only the last 2 digits: 0771234567 → ••••••••67 */
|
||||
export function maskPhone(digits: string) {
|
||||
const clean = normalizePhone(digits)
|
||||
if (clean.length <= 2) return clean
|
||||
return "•".repeat(clean.length - 2) + clean.slice(-2)
|
||||
}
|
||||
|
||||
/** Mask an email, revealing only the first char + domain: ruwan@gmail.com → r••@gmail.com */
|
||||
export function maskEmail(email: string) {
|
||||
const trimmed = email.trim()
|
||||
const at = trimmed.indexOf("@")
|
||||
if (at <= 0) return trimmed
|
||||
const local = trimmed.slice(0, at)
|
||||
const domain = trimmed.slice(at)
|
||||
const shown = local.slice(0, 1)
|
||||
return `${shown}${"•".repeat(Math.max(local.length - 1, 2))}${domain}`
|
||||
}
|
||||
|
||||
/** Whole years between a birthday (yyyy-MM-dd or ISO) and today. */
|
||||
/**
|
||||
* Clamps a raw discount input as the user types: 0-100 for a percentage, 0-maxFlat
|
||||
* (uncapped if maxFlat isn't known yet) for a flat amount. Keeps "" as-is so the field
|
||||
* can be cleared, and leaves non-numeric input untouched rather than fighting the caret.
|
||||
*/
|
||||
export function clampDiscountValue(raw: string, type: "Percentage" | "Flat", maxFlat?: number) {
|
||||
if (raw === "") return raw
|
||||
const n = Number(raw)
|
||||
if (Number.isNaN(n)) return raw
|
||||
const upperBound = type === "Percentage" ? 100 : (maxFlat ?? Infinity)
|
||||
const clamped = Math.min(Math.max(n, 0), upperBound)
|
||||
return clamped === n ? raw : String(clamped)
|
||||
}
|
||||
|
||||
export function ageFromBirthday(birthday: string) {
|
||||
const dob = new Date(birthday)
|
||||
if (Number.isNaN(dob.getTime())) return null
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - dob.getFullYear()
|
||||
const m = now.getMonth() - dob.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < dob.getDate())) age--
|
||||
return age
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list newest-first by a date-ish key. Non-mutating.
|
||||
* Missing/invalid dates sink to the bottom.
|
||||
*/
|
||||
export function sortByNewest<T>(rows: T[], key: (row: T) => string | null | undefined) {
|
||||
return [...rows].sort((a, b) => {
|
||||
const ta = Date.parse(key(a) ?? "")
|
||||
const tb = Date.parse(key(b) ?? "")
|
||||
const va = Number.isNaN(ta) ? -Infinity : ta
|
||||
const vb = Number.isNaN(tb) ? -Infinity : tb
|
||||
return vb - va
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user