Skip to main content

Frontend Development

This guide covers the architecture, UI standards, naming conventions, error handling, and environment setup for the CTMS Web frontend. It is entirely generic — no entity-specific code lives here. Two worked examples demonstrate these patterns in practice:

ExamplePatternWhen to Use
Basic CRUDType → Page → Table → direct fetchSimple single-DocType screens (5 files)
CRUD with ValidationAdds Zod schemas + Backend-for-Frontend (BFF) API routesEntities needing validation or external API access (8 files)
tip

Read the Basic CRUD example first — it covers the minimal file set. Then read the Advanced example to understand when and why to add Zod schemas and API routes.


High-Level Architecture

  • Client components fetch data through the KrakenD API Gateway (handles auth automatically via NEXT_PUBLIC_API_BASE_URL)
  • BFF API routes (optional) provide a server-side proxy with Zod validation — used when external consumers need a public API
  • Frappe stores all domain data as DocTypes in MariaDB

Naming Conventions — DocType to Page

Every Frappe DocType maps to a set of frontend files following a strict naming convention. The DocType name drives all downstream names.

DocType → Slug Derivation

StepRuleExample
Frappe DocTypeAs defined in Frappe (Title Case, may contain spaces)Adverse Event
Slug (singular)Lowercase, kebab-case, drop generic prefixesadverse-event
Slug (plural)Pluralised slugadverse-events
Type nameApi + PascalCase singularApiAdverseEvent

File Naming Map

LayerPathConvention
Typesrc/lib/types/{singular}.tsApi{PascalSingular} export
Schemasrc/lib/schemas/{singular}-schema.ts{singular}Schema, create{Singular}Schema, update{Singular}Schema
Pagesrc/app/{plural}/page.tsxServer Component with <Suspense>
Clientsrc/app/{plural}/{plural}-client.tsx"use client" — state, fetch, CRUD
Tablesrc/components/{plural}/{plural}-table.tsxTanStack Table definition
Formsrc/components/{plural}/{singular}-form.tsxSlide-over form
API Route (list)src/app/api/v1/{plural}/route.tsGET (list) + POST (create)
API Route (single)src/app/api/v1/{plural}/[id]/route.tsGET + PUT + DELETE
info

The Frappe DocType name (with spaces) is passed as-is to fetchFrappeDoctypePaginated() and URL-encoded for direct API calls. Only the file/folder names use kebab-case.


UI Standards

Every entity screen follows the same visual structure. This ensures a consistent user experience across the application.

Standard Page Layout

┌──────────────────────────────────────────────────────────────────────────────┐
│ [Search...] [Columns ▾] [+ Add Entity] │ ← Header (fixed)
├──────────────────────────────────────────────────────────────────────────────┤
│ Column 1 │ Column 2 │ Description │ Status │ Modified │ Actions │ ← Table Header (sticky)
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ [Scrollable Table Body — Dynamic Height: calc(100vh - 180px)] │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ 99,999 records found Rows per page [10 ▾] Page 1 of 24 [◀] [▶] │ ← Footer (fixed)
└──────────────────────────────────────────────────────────────────────────────┘

Layout Rules

ConcernStandard
Containerh-[calc(100vh-180px)] min-h-[500px] flex column
Table headersticky top-0 bg-background z-10
Row heightFixed h-12 for visual consistency
Pagination footerFixed at bottom, outside scrollable area (flex-shrink-0)
Default sortmodified desc (most recent first)
Default page size10 rows, options: [10, 20, 30, 40, 50]

Action Bar

The top row always contains:

  • Left: Search input with debounced filtering
  • Right: [Delete Selected] (conditional) · [Columns ▾] dropdown · [+ Add Entity] primary button
  • All action buttons are wrapped in <ActionGuard> for RBAC

Slide-Over Form (Side Sheet)

All create / edit / view forms open as a right-side slide-over panel:

┌──────────────────────────────────────────┬──────────────────────────┐
│ │ ╔══════════════════════╗│
│ (Backdrop overlay) │ ║ Entity Name ✕ ║│
│ │ ╠══════════════════════╣│
│ │ ║ ║│
│ │ ║ [Scrollable form ║│
│ │ ║ fields here] ║│
│ │ ║ ║│
│ │ ╠══════════════════════╣│
│ │ ║ [Cancel] [Save] ║│
│ │ ╚══════════════════════╝│
└──────────────────────────────────────────┴──────────────────────────┘
ConcernStandard
Panel widthmax-w-md (fixed-width)
HeaderPrimary background, title + close button
BodyScrollable, consistent spacing (px-4 sm:px-6, space-y-6)
FooterFixed at bottom with Cancel / Reset / Save buttons (always visible)
Modescreate (empty form), edit (pre-populated), readOnly (disabled fields)
Animationanimate-in slide-in-from-right duration-500

Delete Confirmation

Destructive actions always show an AlertDialog before execution:

  • Title: "Are you sure?"
  • Description explains the action is irreversible
  • Cancel button + red Delete button
  • Bulk delete shows count: "Delete 3 selected items?"

Notifications (Toasts)

All user-facing messages use sonner — imported as import { toast } from "sonner".

OperationToast
Create successtoast.success("Entity created successfully")
Update successtoast.success("Entity updated successfully")
Delete successtoast.success("Successfully deleted N entity(ies)")
Errortoast.error(errorMessage) — with extracted message from Frappe
Partial bulk deletetoast.error("N deleted, but M failed: reason")
warning

Do not use the shadcn useToast() hook — use sonner exclusively for consistency.


Error Handling

Client-Side CRUD Pattern

Every CRUD operation in a client component follows this structure:

try {
const response = await fetch(url, { method, headers, body })
const responseData = await response.json()

// ⚠️ KrakenD may return HTTP 200 wrapping a Frappe error
if (responseData.error_backend_alias || !response.ok) {
throw new Error(extractErrorMessage(responseData))
}

toast.success("Entity created successfully")
fetchEntities() // re-fetch the list (preserves pagination)
} catch (error) {
console.error("Error:", error)
toast.error(error instanceof Error ? error.message : "An unexpected error occurred")
} finally {
setIsLoading(false)
}

extractErrorMessage() Helper

Each client component includes an error parser for the nested KrakenD → Frappe error structure:

  1. Parse error_backend_alias.http_body._server_messages (JSON array of JSON strings)
  2. Strip HTML tags from extracted message
  3. Fallback to responseData.exception field
  4. Fallback to responseData.error_backend_alias string
  5. Final fallback: "An unexpected error occurred"

Key Error Scenarios

HTTP StatusFrappe ErrorCause
200 with error_backend_aliasVariousKrakenD wraps the Frappe error inside a 200 response
417LinkExistsErrorDelete blocked — linked child records exist
409DuplicateEntryErrorDuplicate name or unique-field violation
403PermissionErrorUser role cannot perform this action

Error UI State

When a page-level fetch fails, the table area shows a red error box with the error message and a "Try Again" button instead of an empty table.


Server-Side Logging (loggedFetch)

BFF API routes (Next.js Route Handlers) use loggedFetch() instead of plain fetch():

FeatureDetail
Filelib/utils/api-logger.ts
TimingLogs request duration in ms
RedactionAuth headers are replaced with ***REDACTED***
Console outputColoured method + status + URL for dev readability
Ring bufferLast 1,000 entries kept in memory (singleton ApiLogger)
ScopeServer-side only — client components use plain fetch()

Loading & Skeleton States

ContextStandard
Table loading5 animated skeleton rows: <div className="h-4 bg-muted animate-pulse rounded" />
Button saving<Loader2 className="mr-2 h-4 w-4 animate-spin" /> + "Saving…" text
Form overlayFull-screen overlay with spinner + "Saving…" (for long operations)
Page suspense<Suspense fallback={<div>Loading...</div>}> in the server component

Shared Helpers & Hooks

These are reused across all entity pages:

HelperFilePurpose
fetchFrappeDoctypePaginated()lib/api/frappe-doctype.tsPaginated fetch for any DocType (count + data in two calls)
extractErrorMessage()Inlined in each clientParse nested KrakenD/Frappe error structure
usePageHeader()hooks/use-page-header.tsSet page title, subtitle, icon
useActionPermissions()hooks/useActionPermissions.tsRBAC — canCreate(), canUpdate(), canDelete()
useAuth()contexts/auth-context.tsxCurrent user profile and roles
useApiBaseUrl()providers/runtime-config-provider.tsxKrakenD gateway base URL
DataTablePaginationcomponents/ui/data-table-pagination.tsxServer-side pagination control
ActionGuardcomponents/guards/ActionGuard.tsxConditionally render actions based on permissions
loggedFetch()lib/utils/api-logger.tsFetch wrapper with logging (BFF routes only)

UI Component Library

All UI primitives come from shadcn/ui in src/components/ui/:

Button · Input · Table · Badge · Sheet · Dialog · AlertDialog · DropdownMenu · Checkbox · Avatar · Tabs · Select · Skeleton · Card · Separator · Tooltip

Data tables use TanStack Table with manualPagination: true for server-side pagination.

Icons come from lucide-react — e.g. Plus, Pencil, Trash2, Loader2, Search, ChevronLeft, ChevronRight.


Key Project Structure

ctms-web/src/
├── app/
│ ├── {plural}/ ← Page routes
│ │ ├── page.tsx ← Server Component (Suspense wrapper)
│ │ └── {plural}-client.tsx ← Client Component (state + CRUD)
│ ├── api/v1/{plural}/ ← BFF API routes (optional)
│ │ ├── route.ts ← GET + POST
│ │ └── [id]/route.ts ← GET + PUT + DELETE
│ ├── error.tsx ← Global error boundary
│ └── not-found.tsx ← 404 page
├── components/
│ ├── {plural}/ ← Feature components
│ │ ├── {plural}-table.tsx ← TanStack Table (columns, actions)
│ │ └── {singular}-form.tsx ← Slide-over form
│ ├── ui/ ← shadcn/ui primitives
│ ├── guards/ ← ActionGuard, RoleGuard
│ └── dropdown-selects/ ← Reusable entity selectors
├── lib/
│ ├── types/{singular}.ts ← TypeScript types (Api{Entity})
│ ├── schemas/{singular}-schema.ts ← Zod schemas (optional)
│ ├── services/{singular}-service.ts ← Service class (optional)
│ ├── api/frappe-doctype.ts ← Paginated fetch helper
│ └── utils/api-logger.ts ← loggedFetch for BFF routes
├── hooks/ ← usePageHeader, useActionPermissions
├── contexts/ ← Auth, Theme contexts
└── providers/ ← RuntimeConfig (apiBaseUrl)

Environment Variables

The following variables must be set for the frontend to work. Copy sample.env to .env.local and fill in values for your environment.

Backend Connection

VariableExampleDescription
FRAPPE_BASE_URLhttp://<host>:8080Frappe backend URL (server-side only)
FRAPPE_API_TOKEN<api_key>:<api_secret>API key:secret for server→Frappe auth. See how to get the token.
NEXT_PUBLIC_API_BASE_URLhttp://<host>:9080/api/v1KrakenD gateway URL used by client-side fetch
NEXT_PUBLIC_FRAPPE_URLhttp://<host>:8080Public Frappe URL for client-side permission checks

Authentication (Supabase)

VariableExampleDescription
SUPABASE_URLhttp://<host>:8000Supabase project URL
SUPABASE_ANON_KEYeyJhbGci...Supabase anonymous key for client auth

Application Settings

VariableExampleDescription
NEXT_PUBLIC_USER_ROLESPatient,Study Designer,...Comma-separated list of available roles
NEXT_PUBLIC_DEFAULT_PRACTITIONER_IDHLC-PRAC-2026-00001Default practitioner for encounter creation
NEXT_PUBLIC_BRAND_NAME(empty for default)Whitelabel brand name override
NEXT_PUBLIC_LOGO_PREFIX/i/zynomi/logoLogo file path prefix (expects -dark, -mark variants)
NEXT_PUBLIC_LOGO_EXTsvgLogo file extension
NEXT_PUBLIC_SITE_URLhttp://localhost:3000Site URL for SEO / OpenGraph

Adding a New Entity (Checklist)

Minimum (Basic CRUD)

  1. Typesrc/lib/types/{singular}.ts — define Api{Entity} type
  2. Pagesrc/app/{plural}/page.tsx — Server Component with <Suspense>
  3. Clientsrc/app/{plural}/{plural}-client.tsx — state, fetch, CRUD, toasts
  4. Tablesrc/components/{plural}/{plural}-table.tsx — TanStack Table with sticky header, pagination footer
  5. Formsrc/components/{plural}/{singular}-form.tsx — slide-over panel with view/edit/create modes
  6. Sidebar — Register route in app sidebar config
  7. RBAC — Add resource name to permissions system

Additional (With Validation)

  1. Schemasrc/lib/schemas/{singular}-schema.ts — Zod create/update schemas
  2. API Routessrc/app/api/v1/{plural}/route.ts + [id]/route.ts

Optional (Complex Flows)

  1. Servicesrc/lib/services/{singular}-service.ts — for multi-step or cross-DocType flows
Reuse the helpers

fetchFrappeDoctypePaginated() in lib/api/frappe-doctype.ts works with any Frappe DocType. Pass the doctype name (with spaces, as in Frappe) and a field list — pagination, count, and search are handled automatically.