• English
  • 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:

    PatternDescription
    <AsyncBoundary>Wraps every data-driven section — handles isPending, isError, and isEmpty states uniformly.
    <ResponsiveList>The primary table/list primitive. Accepts columns: Column<T>[] and data. Simpler than the full DataTable; used where URL-state filtering is done server-side.
    URL search params as filter stateTanStack Router navigate({ search: ... }) keeps filters in the URL for shareable, back-buttoned states.
    Debounced inputs (350ms)Text search inputs debounce before navigating to avoid excessive API calls.
    keepPreviousData on paginated queriesPrevents table flicker during page/filter changes.
    <ConfirmDialog> before destructive mutationsRevoke, delete, enable/disable all show a confirmation step.
    toast (sonner) for mutation feedbackSuccess and error toasts on all mutations.
    Permission guard can('resource.action')Inline checks from useAuth() hide or disable action buttons.
    staleTime: 5 * 60_000 on catalog queriesRoles, permissions, and feature catalogs are cached for 5 minutes.

    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)

    TypeKey fields
    StoreListItemstoreId, storeCode, name, ownerEmail, plan, status, createdAt, lastSeenAt
    StoreControlPlaneFull store profile + currentPrimaryDeviceId
    StoreDevicedeviceId, name, role, status (active | revoked), platform, appVersion, lastSeenAt
    StoreEntitlementkey, displayName, value, source (default | store), grantedBy/At, expiresAt
    FeatureCatalogItemkey, displayName, defaultGranted, enforcement
    SalonResponseprofile, health (SalonHealth), activity (SalonActivityItem[]), asOf, freshness

    API calls (api.ts, salonApi.ts)

    EndpointPurpose
    GET /v1/ops/storesPaginated store list (params: q, plan, status, offset, limit)
    GET /v1/ops/stores/:storeIdFull store detail (profile + devices + config + entitlements + bookingSlug)
    POST /v1/ops/stores/:storeId/activateActivate a store
    POST /v1/ops/stores/:storeId/deactivateDeactivate a store
    GET /v1/ops/featuresFeature catalog (staleTime 5 min)
    PUT /v1/ops/stores/:storeId/entitlements/:featureOverride entitlement ({ granted })
    DELETE /v1/ops/stores/:storeId/entitlements/:featureReset entitlement to default
    GET /v1/ops/stores/:storeId/devicesDevice list
    POST /v1/ops/stores/:storeId/devices/:deviceId/revokeRevoke a device
    GET /v1/ops/salons/:storeId?tab=<tab>Tab-specific salon view

    UI components

    • StoresList — Debounced text search + plan/status dropdowns, ResponsiveList table, 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 a ConfirmDialog; the primary device cannot be revoked without promotion.
    • StoreEntitlements — Renders each FeatureCatalogItem with a Switch to 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 via qrcode.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, freshness
    • HealthTrends — apptVolumePct, revenuePct, activeCustomersPct

    API

    GET /v1/ops/health/salons — params: risk (filter), offset, limit → SalonHealthListResponse { items, offset, limit }

    UI

    • HealthWorklistRiskFilter (multi-select health levels, serialized to URL param risk), ResponsiveList table with columns: Store, Level (HealthLevelBadge), Score, Root Causes (RootCauseChips), Tasks (OpenTasksBadge). Offset pagination. Row click → /stores/$storeId.
    • severity.ts exports SeverityTone and 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 } → updated TaskItem

    Data fetching (useTasks.ts)

    • refetchInterval: 5 * 60_000 (5 min) — task engine runs hourly; human changes reflect immediately.
    • keepPreviousData for seamless tab/filter switching.
    • invalid_transition 409 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 and storeId is passed as a prop.
    • useOpenTaskCounts.ts fetches up to 500 open tasks and reduces them to a Map<storeId, count> for the OpenTasksBadge in 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, breadcrumbs
    • DeviceMetric — appVersion, errorCount, apiFailureCount, outboxBacklog, emergencySwitchCount, logLevel
    • BundleInfo — bundleId, deviceId, commandId, sizeBytes, sha256, receivedAt
    • CommandRow — commandId, type, params, status, createdAt, deliveredAt, ackedAt, expiresAt, error

    API (api.ts)

    EndpointPurpose
    GET /v1/ops/salons/:storeId/diagnostics?section=<section>&…Fetch a diagnostics section (events/devices/bundles/commands)
    GET /v1/ops/salons/:storeId/bundles/:bundleId/contentRead lines from a crash bundle
    GET /v1/ops/salons/:storeId/bundles/:bundleId/downloadBinary download (blob, via apiFetchRaw)
    POST /v1/ops/salons/:storeId/commands/upload-logs[?fromTs=]Request device to upload logs
    POST /v1/ops/salons/:storeId/commands/:commandId/cancelCancel a pending command

    UI (DiagnosticsPanel.tsx + 4 section components)

    • DevicesSection — Heartbeat metrics table; errorCount, apiFailureCount, outboxBacklog, emergencySwitchCount highlighted red when > 0. Includes a DeviceMetricsChart.
    • EventsSection — Filterable by type, severity, date range. Client-side paging (PAGE_SIZE=25). Expandable rows showing stack/context/breadcrumbs.
    • BundlesSection — List bundles; "View bundle" opens BundleContentSheet; download triggers binary fetch.
    • CommandsSection — Lists commands with status; cancel button handles 404 (already resolved) gracefully.
    • useDiagnosticsSection — lazy: only fetches when enabled=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 storeId and actor inputs (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 → Dialog with 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

    EndpointPurpose
    GET /v1/ops/operatorsList all operators
    POST /v1/ops/operatorsCreate operator → { ok, id }
    PUT /v1/ops/operators/:id/rolesReplace role assignments
    POST /v1/ops/operators/:id/enableEnable operator
    POST /v1/ops/operators/:id/disableDisable operator

    UI

    • OperatorsList — Client-side email search, CreateOperatorDialog (email + displayName + roles), EditRolesDialog (checkbox-based via RoleCheckboxes), enable/disable with ConfirmDialog. All mutations require can('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

    EndpointPurpose
    GET /v1/ops/rolesList roles (staleTime 5 min)
    POST /v1/ops/rolesCreate role → { ok, id, code }
    PUT /v1/ops/roles/:id/permissionsReplace permissions
    DELETE /v1/ops/roles/:idDelete role
    GET /v1/ops/permissionsPermission catalog (staleTime 5 min)

    UI

    • RolesListTypeBadge distinguishes Admin / System / Custom roles. Admin role permissions shown as "all (wildcard)"; Edit disabled for admin. Delete disabled for system roles.
    • EditPermissionsDialogPermissionCheckboxes groups permissions by resource prefix (first segment before .). All mutations require can('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 } with data: { count, sample[] }
    • Freshness'fresh' | 'stale' | 'no-data'

    API

    GET /v1/ops/overviewOverviewResponse { widgets: Widget[] }

    UI (Dashboard.tsx and components/)

    • WidgetCard — shared card with title, large count number (colored by SeverityTone), 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.

    Sub-routeContent
    /settingsProfile form (username, email, bio, URL array)
    /settings/accountAccount settings
    /settings/appearanceTheme/font form
    /settings/notificationsNotification preferences
    /settings/displayDisplay preferences

    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

    1. Create src/features/<name>/ with at minimum types.ts, api.ts, and a component file.
    2. Add a route file at src/routes/_authenticated/<name>.tsx.
    3. Add a nav entry in src/components/layout/data/sidebar-data.ts with a permission guard.
    4. Create src/test/features/<name>/ with API hook tests and component tests.
    5. Use apiFetch from src/lib/httpClient.ts for all API calls.
    6. Use <AsyncBoundary> for loading/error/empty states and <ResponsiveList> for tabular data.