diff --git a/CLAUDE.md b/CLAUDE.md
index 1973f9f..5feba50 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,7 +40,7 @@
- `bun run format` — format all files with Prettier
## API Conventions
-- All list endpoints support server-side pagination, search, and sorting via query params:
+- **Every endpoint that returns a list must support pagination, search, and sorting** — no exceptions unless the endpoint is explicitly a lightweight lookup (see below)
- `?page=1&limit=25` — pagination (default: page 1, 25 per page, max 100)
- `?q=search+term` — full-text search across relevant columns
- `?sort=name&order=asc` — sorting by field name, asc or desc
@@ -48,6 +48,15 @@
- Search and filtering is ALWAYS server-side, never client-side
- Use `PaginationSchema` from `@forte/shared/schemas` to parse query params
- Use pagination helpers from `packages/backend/src/utils/pagination.ts`
+- **Lookup endpoints** (e.g., `/roles/all`, `/statuses/all`) are the exception — these return a flat unpaginated list for populating dropdowns/selects. Use a `/all` suffix to distinguish from the paginated list endpoint for the same resource.
+
+## Frontend Table Conventions
+- **Every table that displays data must use the shared `DataTable` component** (`components/shared/data-table.tsx`)
+- All tables must support: **search** (via `?q=`), **sortable columns**, and **server-side pagination**
+- Use the `usePagination()` hook (`hooks/use-pagination.ts`) — it manages page, search, and sort state via URL params
+- All data columns that make sense to sort by should be sortable (e.g., name, email, date, status) — don't limit to just 1-2 columns
+- Sub-resource tables (e.g., members within an account, payment methods) follow the same rules — use `DataTable` with pagination, not raw `
` with unbounded queries
+- Loading states should use skeleton loading (built into `DataTable`), not plain "Loading..." text
## Conventions
- Shared Zod schemas are the single source of truth for validation (used on both frontend and backend)
diff --git a/packages/admin/src/api/identifiers.ts b/packages/admin/src/api/identifiers.ts
index d005682..a858db8 100644
--- a/packages/admin/src/api/identifiers.ts
+++ b/packages/admin/src/api/identifiers.ts
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { MemberIdentifier } from '@/types/account'
+import type { PaginatedResponse } from '@forte/shared/schemas'
export const identifierKeys = {
all: (memberId: string) => ['members', memberId, 'identifiers'] as const,
@@ -9,7 +10,7 @@ export const identifierKeys = {
export function identifierListOptions(memberId: string) {
return queryOptions({
queryKey: identifierKeys.all(memberId),
- queryFn: () => api.get<{ data: MemberIdentifier[] }>(`/v1/members/${memberId}/identifiers`),
+ queryFn: () => api.get>(`/v1/members/${memberId}/identifiers`, { page: 1, limit: 100, order: 'asc' }),
})
}
diff --git a/packages/admin/src/api/payment-methods.ts b/packages/admin/src/api/payment-methods.ts
index 218cc96..2367368 100644
--- a/packages/admin/src/api/payment-methods.ts
+++ b/packages/admin/src/api/payment-methods.ts
@@ -1,15 +1,17 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { PaymentMethod } from '@/types/account'
+import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
export const paymentMethodKeys = {
all: (accountId: string) => ['accounts', accountId, 'payment-methods'] as const,
+ list: (accountId: string, params: PaginationInput) => [...paymentMethodKeys.all(accountId), params] as const,
}
-export function paymentMethodListOptions(accountId: string) {
+export function paymentMethodListOptions(accountId: string, params: PaginationInput) {
return queryOptions({
- queryKey: paymentMethodKeys.all(accountId),
- queryFn: () => api.get<{ data: PaymentMethod[] }>(`/v1/accounts/${accountId}/payment-methods`),
+ queryKey: paymentMethodKeys.list(accountId, params),
+ queryFn: () => api.get>(`/v1/accounts/${accountId}/payment-methods`, params),
})
}
diff --git a/packages/admin/src/api/processor-links.ts b/packages/admin/src/api/processor-links.ts
index 316ea56..66743b6 100644
--- a/packages/admin/src/api/processor-links.ts
+++ b/packages/admin/src/api/processor-links.ts
@@ -1,15 +1,17 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { ProcessorLink } from '@/types/account'
+import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
export const processorLinkKeys = {
all: (accountId: string) => ['accounts', accountId, 'processor-links'] as const,
+ list: (accountId: string, params: PaginationInput) => [...processorLinkKeys.all(accountId), params] as const,
}
-export function processorLinkListOptions(accountId: string) {
+export function processorLinkListOptions(accountId: string, params: PaginationInput) {
return queryOptions({
- queryKey: processorLinkKeys.all(accountId),
- queryFn: () => api.get<{ data: ProcessorLink[] }>(`/v1/accounts/${accountId}/processor-links`),
+ queryKey: processorLinkKeys.list(accountId, params),
+ queryFn: () => api.get>(`/v1/accounts/${accountId}/processor-links`, params),
})
}
diff --git a/packages/admin/src/api/rbac.ts b/packages/admin/src/api/rbac.ts
index a47d52c..9db3e4a 100644
--- a/packages/admin/src/api/rbac.ts
+++ b/packages/admin/src/api/rbac.ts
@@ -1,10 +1,12 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Permission, Role } from '@/types/rbac'
+import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
export const rbacKeys = {
permissions: ['permissions'] as const,
roles: ['roles'] as const,
+ roleList: (params: PaginationInput) => ['roles', 'list', params] as const,
role: (id: string) => ['roles', id] as const,
userRoles: (userId: string) => ['users', userId, 'roles'] as const,
myPermissions: ['me', 'permissions'] as const,
@@ -17,10 +19,19 @@ export function permissionListOptions() {
})
}
+/** All roles (for dropdowns, selectors) */
export function roleListOptions() {
return queryOptions({
queryKey: rbacKeys.roles,
- queryFn: () => api.get<{ data: Role[] }>('/v1/roles'),
+ queryFn: () => api.get<{ data: Role[] }>('/v1/roles/all'),
+ })
+}
+
+/** Paginated roles (for the roles list page) */
+export function rolePageOptions(params: PaginationInput) {
+ return queryOptions({
+ queryKey: rbacKeys.roleList(params),
+ queryFn: () => api.get>('/v1/roles', params as Record),
})
}
diff --git a/packages/admin/src/api/tax-exemptions.ts b/packages/admin/src/api/tax-exemptions.ts
index 713a98c..418bde9 100644
--- a/packages/admin/src/api/tax-exemptions.ts
+++ b/packages/admin/src/api/tax-exemptions.ts
@@ -1,15 +1,17 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { TaxExemption } from '@/types/account'
+import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
export const taxExemptionKeys = {
all: (accountId: string) => ['accounts', accountId, 'tax-exemptions'] as const,
+ list: (accountId: string, params: PaginationInput) => [...taxExemptionKeys.all(accountId), params] as const,
}
-export function taxExemptionListOptions(accountId: string) {
+export function taxExemptionListOptions(accountId: string, params: PaginationInput) {
return queryOptions({
- queryKey: taxExemptionKeys.all(accountId),
- queryFn: () => api.get<{ data: TaxExemption[] }>(`/v1/accounts/${accountId}/tax-exemptions`),
+ queryKey: taxExemptionKeys.list(accountId, params),
+ queryFn: () => api.get>(`/v1/accounts/${accountId}/tax-exemptions`, params),
})
}
diff --git a/packages/admin/src/api/users.ts b/packages/admin/src/api/users.ts
index b13ffc4..f24ac69 100644
--- a/packages/admin/src/api/users.ts
+++ b/packages/admin/src/api/users.ts
@@ -1,5 +1,13 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
+import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
+
+export interface UserRole {
+ id: string
+ name: string
+ slug: string
+ isSystem: boolean
+}
export interface UserRecord {
id: string
@@ -7,16 +15,31 @@ export interface UserRecord {
firstName: string
lastName: string
role: string
+ isActive: boolean
createdAt: string
+ roles: UserRole[]
}
export const userKeys = {
+ list: (params: PaginationInput) => ['users', params] as const,
roles: (userId: string) => ['users', userId, 'roles'] as const,
}
+export function userListOptions(params: PaginationInput) {
+ return queryOptions({
+ queryKey: userKeys.list(params),
+ queryFn: () => api.get>('/v1/users', params as Record),
+ })
+}
+
export function userRolesOptions(userId: string) {
return queryOptions({
queryKey: userKeys.roles(userId),
- queryFn: () => api.get<{ data: { id: string; name: string; slug: string; isSystem: boolean }[] }>(`/v1/users/${userId}/roles`),
+ queryFn: () => api.get<{ data: UserRole[] }>(`/v1/users/${userId}/roles`),
})
}
+
+export const userMutations = {
+ toggleStatus: (userId: string, isActive: boolean) =>
+ api.patch<{ id: string; isActive: boolean }>(`/v1/users/${userId}/status`, { isActive }),
+}
diff --git a/packages/admin/src/components/accounts/identifier-form.tsx b/packages/admin/src/components/accounts/identifier-form.tsx
index bdc386c..a11d97a 100644
--- a/packages/admin/src/components/accounts/identifier-form.tsx
+++ b/packages/admin/src/components/accounts/identifier-form.tsx
@@ -1,12 +1,12 @@
import { useForm } from 'react-hook-form'
-import { useRef } from 'react'
+import { useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { MemberIdentifier } from '@/types/account'
-import { Upload } from 'lucide-react'
+import { Upload, X } from 'lucide-react'
const ID_TYPES = [
{ value: 'drivers_license', label: "Driver's License / State ID" },
@@ -14,22 +14,18 @@ const ID_TYPES = [
{ value: 'school_id', label: 'School ID' },
]
+export interface IdentifierFiles {
+ front?: File
+ back?: File
+}
+
interface IdentifierFormProps {
memberId: string
defaultValues?: Partial
- onSubmit: (data: Record) => void
+ onSubmit: (data: Record, files: IdentifierFiles) => void
loading?: boolean
}
-function fileToBase64(file: File): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => resolve(reader.result as string)
- reader.onerror = reject
- reader.readAsDataURL(file)
- })
-}
-
export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: IdentifierFormProps) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
@@ -41,20 +37,38 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I
expiresAt: defaultValues?.expiresAt ?? '',
notes: defaultValues?.notes ?? '',
isPrimary: defaultValues?.isPrimary ?? false,
- imageFront: defaultValues?.imageFront ?? '',
- imageBack: defaultValues?.imageBack ?? '',
},
})
const frontInputRef = useRef(null)
const backInputRef = useRef(null)
- const imageFront = watch('imageFront')
- const imageBack = watch('imageBack')
+ const [frontFile, setFrontFile] = useState(null)
+ const [backFile, setBackFile] = useState(null)
+ const [frontPreview, setFrontPreview] = useState(null)
+ const [backPreview, setBackPreview] = useState(null)
const idType = watch('type')
- async function handleFileSelect(field: 'imageFront' | 'imageBack', file: File) {
- const base64 = await fileToBase64(file)
- setValue(field, base64)
+ function handleFileSelect(side: 'front' | 'back', file: File) {
+ const url = URL.createObjectURL(file)
+ if (side === 'front') {
+ setFrontFile(file)
+ setFrontPreview(url)
+ } else {
+ setBackFile(file)
+ setBackPreview(url)
+ }
+ }
+
+ function clearFile(side: 'front' | 'back') {
+ if (side === 'front') {
+ if (frontPreview) URL.revokeObjectURL(frontPreview)
+ setFrontFile(null)
+ setFrontPreview(null)
+ } else {
+ if (backPreview) URL.revokeObjectURL(backPreview)
+ setBackFile(null)
+ setBackPreview(null)
+ }
}
function handleFormSubmit(data: Record) {
@@ -62,14 +76,17 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I
for (const [key, value] of Object.entries(data)) {
cleaned[key] = value === '' ? undefined : value
}
- onSubmit(cleaned)
+ onSubmit(cleaned, {
+ front: frontFile ?? undefined,
+ back: backFile ?? undefined,
+ })
}
return (