Feature Modules
All business features live under src/features/. Each module follows the same structure: types.ts for TypeScript types, api.ts (and sometimes additional *Api.ts files) for TanStack Query hooks and API calls, and one or more component files. Tests mirror this structure under src/test/features/.
Cross-cutting UI patterns
Before the feature catalogue, here are the patterns every feature uses:
Stores (src/features/stores/)
Route: /stores (list), /stores/$storeId (workspace)
Permission needed: Basic authenticated access for list; specific tabs gated (see below).
The largest and most central feature. Manages the full lifecycle of salon stores on the platform.
Types (types.ts, salonTypes.ts)
API calls (api.ts, salonApi.ts)
UI components
StoresList— Debounced text search + plan/status dropdowns,ResponsiveListtable, offset pagination. Clicking a row navigates to/stores/$storeId.StoreWorkspace— 7-tab layout: Overview, Health, Activity, Config, Devices, Diagnostics, Tasks. Tabs for Health, Diagnostics (support.read), and Tasks (tasks.read) are gated by permission. Refreshing invalidates all related query keys.StoreDevices— Device list with active/revoked badges. "Revoke" shows aConfirmDialog; the primary device cannot be revoked without promotion.StoreEntitlements— Renders eachFeatureCatalogItemwith aSwitchto override the entitlement and a "Reset to default" button. Effective state only applies to active stores.StoreBooking— Displays the booking page URL, copy-to-clipboard, and QR code dialog viaqrcode.react.OverviewTab— Store profile fields (name, status, timezone, address) + embedded health score/level.
Health (src/features/health/)
Route: /health
Permission needed: health.read (nav item gated)
Fleet-wide health worklist for monitoring salon risk levels.
Types (types.ts)
HealthLevel—'Critical' | 'At-Risk' | 'Monitor' | 'Healthy' | 'Stale' | 'Unknown'RootCause— 11 values: scoring-type (setup,activity,forward_bookings,appt_volume_trend,revenue_trend,active_customers_trend) and gate-type (no_data,sync_stale,account_suspended,account_cancelled,billing_past_due)SalonHealthItem— storeId, name, level, severityRank, score (null for gate-type causes), rootCause[], asOf, freshnessHealthTrends— apptVolumePct, revenuePct, activeCustomersPct
API
GET /v1/ops/health/salons — params: risk (filter), offset, limit → SalonHealthListResponse { items, offset, limit }
UI
HealthWorklist—RiskFilter(multi-select health levels, serialized to URL paramrisk),ResponsiveListtable with columns: Store, Level (HealthLevelBadge), Score, Root Causes (RootCauseChips), Tasks (OpenTasksBadge). Offset pagination. Row click →/stores/$storeId.severity.tsexportsSeverityToneand Tailwind CSS custom-property helpers (--sev-*). Used across health, tasks, and diagnostics for consistent color coding.
Tasks (src/features/tasks/)
Route: /tasks (standalone), also embedded inside StoreWorkspace
Permission needed: tasks.read (nav item gated)
Operator task queue auto-generated from health/device/sync rules. Tasks surface actionable items for operators to acknowledge and resolve.
Types (types.ts)
TaskStatus—'open' | 'in_progress' | 'done' | 'dismissed'TaskTransition—'in_progress' | 'done' | 'dismissed'(valid operator-driven transitions)TaskKind—'merchant_health' | 'device' | 'sync'RuleKey—'health_at_risk' | 'device_offline' | 'store_not_synced'TaskItem— id, storeId, storeName, kind, title, status, priority, ownerRole, assignee, resolvedBy ('rule'= auto-resolved), ruleKey, dueAt (always null in v1), openedAt, resolvedAt, updatedAt, detail (typed union)
API (api.ts)
GET /v1/ops/tasks— params: storeId, status, offset, limit →{ items, total }POST /v1/ops/tasks/:id/status—{ status: TaskTransition }→ updatedTaskItem
Data fetching (useTasks.ts)
refetchInterval: 5 * 60_000(5 min) — task engine runs hourly; human changes reflect immediately.keepPreviousDatafor seamless tab/filter switching.invalid_transition409 is handled gracefully: re-fetches and shows "This task was just changed — refreshed" toast.
UI (TasksWorklist.tsx)
- Primary tab filter:
unresolved(open + in_progress) /in_progress/done/dismissed. - Secondary client-side filters: task kind, "Mine only" (assignee === current user email).
- Priority badge: P1 = critical, P2+ = at-risk (via
SeverityTone). - When embedded in
StoreWorkspace, the Store column is hidden andstoreIdis passed as a prop. useOpenTaskCounts.tsfetches up to 500 open tasks and reduces them to aMap<storeId, count>for theOpenTasksBadgein the health worklist.
Diagnostics (src/features/diagnostics/)
Route: Embedded as a tab in /stores/$storeId (Diagnostics tab)
Permission needed: support.read (<RequirePermission> wrapper)
Per-store technical diagnostics for support operators. Most test-heavy feature (18 test files).
Types (types.ts)
DiagnosticsSection—'events' | 'devices' | 'bundles' | 'commands'EventRow— eventId, deviceId, type, severity, occurredAt (unix ms), message, stack, context, breadcrumbsDeviceMetric— appVersion, errorCount, apiFailureCount, outboxBacklog, emergencySwitchCount, logLevelBundleInfo— bundleId, deviceId, commandId, sizeBytes, sha256, receivedAtCommandRow— commandId, type, params, status, createdAt, deliveredAt, ackedAt, expiresAt, error
API (api.ts)
UI (DiagnosticsPanel.tsx + 4 section components)
DevicesSection— Heartbeat metrics table; errorCount, apiFailureCount, outboxBacklog, emergencySwitchCount highlighted red when > 0. Includes aDeviceMetricsChart.EventsSection— Filterable by type, severity, date range. Client-side paging (PAGE_SIZE=25). Expandable rows showing stack/context/breadcrumbs.BundlesSection— List bundles; "View bundle" opensBundleContentSheet; download triggers binary fetch.CommandsSection— Lists commands with status; cancel button handles 404 (already resolved) gracefully.useDiagnosticsSection— lazy: only fetches whenenabled=true(active tab), preventing unnecessary network calls.
Audit (src/features/audit/)
Route: /audit
Permission needed: Authenticated (no specific permission gating in nav)
Immutable log of all operator actions against the platform.
Types (types.ts)
AuditEntry— id, action, targetStoreId (nullable), actor, result, detail (nullable JSON), createdAt
API
GET /v1/ops/audit — params: storeId, actor, action, from, to, offset, limit → AuditListResponse { total, offset, limit, items }
Known action strings (hardcoded in AuditLog.tsx)
operator.login, operator.logout, store.activate, store.deactivate, store.entitlement.set, store.entitlement.clear, device.revoke
UI (AuditLog.tsx)
- Debounced
storeIdandactorinputs (350ms). Action dropdown. Date-range from/to inputs. - Table: action, actor, targetStoreId (links to
/stores/$storeId), result badge (green=ok, red=error), createdAt. - Row click →
Dialogwith pretty-printed JSON detail. - Offset-based pagination.
Operators (src/features/operators/)
Route: /operators
Permission needed: operators.manage (for mutations; listing is available to authenticated users)
Manages internal admin user accounts.
Types (types.ts)
Operator— id, email, displayName?, status (active|disabled), roles (string[] of role codes)CreateOperatorInput— email, displayName?, roles
API
UI
OperatorsList— Client-side email search,CreateOperatorDialog(email + displayName + roles),EditRolesDialog(checkbox-based viaRoleCheckboxes), enable/disable withConfirmDialog. All mutations requirecan('operators.manage').- Role names resolved by joining against the
useRoles()catalog; falls back to the raw role code string.
Roles (src/features/roles/)
Route: /roles
Permission needed: roles.manage (for mutations)
RBAC role management. The permission model uses a two-tier catalog: roles (named groups) contain permissions (resource-action codes).
Types (types.ts)
Role— id, code, name, isSystem (seeded: admin/management/support/viewer), isAdmin (wildcard), permissions (string[])Permission— code, description
API
UI
RolesList—TypeBadgedistinguishes Admin / System / Custom roles. Admin role permissions shown as "all (wildcard)"; Edit disabled for admin. Delete disabled for system roles.EditPermissionsDialog—PermissionCheckboxesgroups permissions by resource prefix (first segment before.). All mutations requirecan('roles.manage').
Overview (src/features/overview/)
Route: / (the authenticated landing page)
Fleet-level KPI dashboard. Fetches once on mount; no polling.
Types (types.ts)
WidgetKind—'offline_devices' | 'stores_not_synced' | 'pending_applications' | 'app_crashes' | 'tasks'- Each widget extends
WidgetMeta { asOf, freshness }withdata: { count, sample[] } Freshness—'fresh' | 'stale' | 'no-data'
API
GET /v1/ops/overview → OverviewResponse { widgets: Widget[] }
UI (Dashboard.tsx and components/)
WidgetCard— shared card with title, large count number (colored bySeverityTone), freshness badge, "View all" overflow link. Accepts children for sample list.- Five widgets:
OfflineDevicesWidget,StoresNotSyncedWidget,PendingApplicationsWidget,AppCrashesWidget,TasksWidget.
Settings (src/features/settings/)
Route: /settings/* (nested sub-routes)
User preference pages. Largely a UI scaffold — no live API calls. Forms use Zod validation and react-hook-form but submit to showSubmittedData (console output in dev) rather than a real endpoint.
Layout: sticky SidebarNav, <Outlet /> for sub-page content.
Dashboard (src/features/dashboard/)
Route: /dashboard
A static demo scaffold retained from the shadcn-admin template. Contains hardcoded data (sales figures, user names, chart data). Not connected to any real API. Used for structural/template reference only.
Components: overview.tsx (Recharts BarChart), analytics.tsx (Recharts AreaChart), recent-sales.tsx (hardcoded list). Tabs: Overview, Analytics (with disabled Reports/Notifications).
Adding a new feature
- Create
src/features/<name>/with at minimumtypes.ts,api.ts, and a component file. - Add a route file at
src/routes/_authenticated/<name>.tsx. - Add a nav entry in
src/components/layout/data/sidebar-data.tswith apermissionguard. - Create
src/test/features/<name>/with API hook tests and component tests. - Use
apiFetchfromsrc/lib/httpClient.tsfor all API calls. - Use
<AsyncBoundary>for loading/error/empty states and<ResponsiveList>for tabular data.