Admin UI
Standards
Canonical patterns for seven recurring UI structures in the Sixhands admin prototype. All examples use live design tokens. Follow these specs exactly — no one-offs.
Inline Alert
Component: InlineAlert · File: shared-blocks.jsx · Props: kind, children, action?
role="alert" set automatically.| Kind | Use when | Don't use when |
|---|---|---|
| info | Persistent context the user needs on-page — POS constraint, read-only scope, data lag warning | Content is a prototype note (→ note). Content is a confirmation (→ success). |
| warn | Attention needed before proceeding — billing degraded, destructive confirmation, time-sensitive | Flagging prototype-only scope limits for DX (→ note). |
| error | An error occurred and action is needed — form failed, sync broken, auth rejected | Something might go wrong (→ warn). Toast is better for transient errors. |
| success | Action completed, user needs persistent confirmation on the page | Transient confirmations (→ Toast). Neutral entity status (→ StatusPill). |
| note | Prototype only. Scope constraints, deferred features, DX implementation notes | Never in production UI. If real users see it, use info or warn. |
| Property | Value |
|---|---|
| Padding | 12px 16px |
| Border radius | var(--r-sm) = 8px |
| Icon | 22×22px circle · filled with fg color · cream text · always present |
| Body font | 500 14px/1.5 Inter |
| Optional action | Right-aligned · same fg color · 600 weight · single CTA only — never two actions in one alert |
| Placement | Below PageHead, above FilterBar / content. Never inside a table row or drawer header. |
| Stacking | Max 2 per page. If warn + info both present, warn goes first. |
- Use
notefor any text starting with "DX:" or describing prototype scope - Place alert directly below
PageHeadas first child in page body - Use
successfor persistent confirmations; Toast for transient ones - Keep body text to 1–2 lines — link out for more detail
- Don't use
infoorwarnfor prototype notes — DX can't tell the difference - Don't stack more than 2 alerts on a single page
- Don't place an
InlineAlertinside aDetailDrawerheader — put it in the drawer body - Don't use
errorfor "might fail" — that'swarn
PageHead + Actions
Component: PageHead · File: shared.jsx · Props: title, description?, back?, status?, actions?
Franchisees
kind="user" — four-state user lifecyclekind="customer" — three-state customer lifecycle (note: Inactive is amber here — a soft-warn dormant state, distinct from Archive grey; unlike kind="user" where Inactive is neutral)kind="reward" — reward lifecycle + approval (Pending is info-blue, matching the partner/promo approval siblings; Approved green, Rejected red)kind="automation" — Automations module on/off (Active green · Disabled neutral; existing tones, zero new CSS)QrPanel — read-only QR placeholder for account-created automations (a Lucide QrCode glyph stands in for the rendered code; short sign-up link + Download / Regenerate). Reuses canonical .qr-panel* classes.CountryChip — data-agnostic geo tag (.geo-tag): caller passes flag glyph + ISO code (mint-soft bg, forest-deep text, pill radius)Edit franchisee
Global dashboard
description prop — muted subtitle below titleLegal Policies
| Rule | Detail |
|---|---|
| Back navigation | Always use the back prop — never put a "← Back" or "Back to X →" ghost button inside actions. The back prop renders a small breadcrumb link above the title. |
| Title format | List pages: plain noun ("Franchisees", "Outlets"). Detail pages: EntityName · Type ("Thai Hots Group · Franchisee"). New record: "New [entity]". |
| Actions area | Right-aligned. Buttons only — primary rightmost, secondary left of primary. Never more than 2–3 buttons. StatusPill never goes here — use the status prop instead. |
| Action button order | Left → right: destructive (danger) · secondary · primary. Never flip this order. |
| Status in actions | Use the status prop on detail pages — renders left-aligned below the title. Never in actions. |
| Page vs. tab actions | actions is for page-scoped buttons only — buttons that apply regardless of which tab is active (e.g., Edit, Suspend on a Franchisee page). Tab-specific actions (e.g., Add Outlet on the Outlets tab, Invite User on the Users tab) must live inside the tab content panel, not in PageHead. A good test: if switching tabs should hide the button, it belongs in the tab. |
| Property | Value |
|---|---|
| Title font | 700 40px/1.0 Nohemi · color var(--forest) · letter-spacing -.01em |
| Back link | 600 15px/1 Inter · color var(--ink-2) · no underline · ArrowLeft 18px icon · min-height 44px (tablet touch target) · padding 10px 4px |
| Row alignment | align-items: flex-end — back link sits above title, actions align to baseline of title |
| Gap (back → title) | 4px column gap |
| Gap (actions) | 8px between action items |
- Always use
back={{ label: "Franchisees", onClick: … }}prop for back nav on detail pages - Follow the title format: plain noun for lists,
Name · Typefor details - Put StatusPill leftmost in actions on detail/view pages
- Use the same
PageHeadcomponent for every screen — no custom h1 elements
- Don't put
<Btn variant="ghost">Back to X →</Btn>inactions— use thebackprop - Don't put more than 3 elements in the actions area
- Don't use raw
<h1 className="h-page">— always use thePageHeadcomponent - Don't put a form Save button in PageHead if the form is inside a DetailDrawer
- Don't put tab-specific actions in
PageHead.actions— if switching tabs would make the button irrelevant, it belongs inside the tab panel
Table List
Component: Table · File: shared.jsx · Above: BulkBar (when selected), FilterBar · Below: TableFooter
| Franchisee | Country | Outlets | Status | |
|---|---|---|---|---|
| Thai Hots Group | 🇵🇭 Philippines | 12 | Active | |
| Manila Fresh Co. | 🇵🇭 Philippines | 4 | Pending | |
| Green Bowl PH | 🇵🇭 Philippines | 7 | Active | |
| No results match your filter | ||||
BulkBar appears above the table, below FilterBar — animated in when ≥1 row is selected. Disappears when selection is cleared.
✓ |
Franchisee | Country | Status |
|---|---|---|---|
✓ |
Thai Hots Group | 🇵🇭 Philippines | Active |
| Manila Fresh Co. | 🇵🇭 Philippines | Pending | |
✓ |
Green Bowl PH | 🇵🇭 Philippines | Active |
| Element | Detail |
|---|---|
| Placement | Above the Table, immediately below FilterBar. Never inside the table or in the page header. |
| Trigger | Renders when selectedCount ≥ 1; hidden (returns null) when 0. Animate in with bulkIn keyframe. |
| Left: count + deselect | "N selected" label + "Deselect all" underline link in --lime. |
| Right: actions | Pass <Btn> components via actions prop. Danger actions leftmost; primary/confirm rightmost. |
| Component | <BulkBar selectedCount={n} onDeselect={fn} actions={…}/> — file: shared.jsx |
| Franchisee | Country | Outlets | Status |
|---|
| Rule | Detail |
|---|---|
| Primary column | Every table has exactly one primary column — the entity name/identifier. Style: 700 14px/1.2 Inter, color var(--forest). Always the leftmost data column. Use render: r => <span style=…> — never global inline styles repeated per-table. |
| Row click | Clicking any row opens the DetailDrawer for that record. The row must have cursor: pointer and a hover background of rgba(0,106,86,.03). Never navigate away on row click — that's what the drawer is for. Exception (Network modules — Countries, Outlets, Franchisees, Areas, Users): their detail is a full page (03-ui convention 2), so the row click navigates to the full-page Detail instead of a drawer. For Users, OM (who manages no one) has no list — the nav lands directly on their own full-page detail titled "Me". |
| Row actions | Reveal on hover only (opacity 0 → 1). Max 2 icon buttons per row. Use iconbtn class for the 28×28px icon buttons. Place in rightmost column. |
| Numeric columns | Use Mono component (or font-feature-settings: "tnum") for IDs, counts, amounts. Right-align currency columns only. |
| Empty state | Filtered zero results: inline empty row with muted italic message. No records at all: use EmptyState component below the table header. |
| Sortable columns | Add sortable: true (and optionally sortKey) to a column definition. The Table component manages sort state internally. Active column highlights in --lime; idle sort icon is dimmed (opacity .45). Never implement custom sort outside the component. |
| FilterBar + Search | Always present above every table. Include a SearchBar as the first element on list / table pages by passing search + onSearch props. Dashboards and scope-only filter strips (e.g. country + date-range) may omit search — when onSearch is not supplied, the search slot is hidden automatically. Additional Select filters appear after search. Use FilterBar component — never custom filter UI. |
| TableFooter | Use TableFooter below every paginated table. 3-col layout: left = "Show N per page" dropdown (default 20); centre = page numbers; right = Prev + Next grouped. Never split Prev and Next to opposite ends. Legacy Pagination/Showing are kept for backward compat only. |
| Multi-select | When a table supports row selection, render BulkBar above the table (below FilterBar) when selectedCount ≥ 1. Never embed bulk actions inside the table footer or page header. |
| Property | Value |
|---|---|
| Header font | 700 11px/1 Inter · uppercase · letter-spacing .08em · color var(--mint) |
| Sort icon — idle | Dual-arrow Ic.sort(14) · opacity .45 · cursor: pointer on th |
| Sort icon — active asc | Filled up-arrow Ic.sortAsc(14) · color var(--lime) |
| Sort icon — active desc | Filled down-arrow Ic.sortDesc(14) · color var(--lime) |
| Header border | 1.5px solid var(--line) |
| Row border | 1px solid var(--line) |
| Cell padding | 13px 16px 13px 0 (no left padding on first col) |
| Row hover bg | rgba(0,106,86,.03) |
| Primary col style | 700 14px/1.2 Inter · color var(--forest) |
| Secondary col style | 400 13px/1.4 Inter · color var(--ink-2) |
| Numeric col style | 600 12px/1 monospace · font-feature-settings "tnum" |
- Always define the primary column style in the
columnsarray'srenderfunction — not repeated inline across screens - Use
FilterBarabove every filterable table - Open
DetailDraweron row click — never navigate away - Show both
Showingcount andPaginationtogether
- Don't repeat
font: "700 14px/1.2 var(--font-ui)", color: "var(--forest)"inline in every table's render — extract to a shared pattern - Don't show row actions at full opacity — they reveal on hover only
- Don't add a "View" or "Details" text link in the row — row click IS the view action
- Don't omit the
SearchBarfromFilterBar— search is mandatory on every filterable table - Don't implement sort outside the
Tablecomponent — usesortable: true+ optionalsortKeyin the column definition
FilterBar
Component: FilterBar · File: shared-blocks.jsx · v2.2 Props: search, onSearch, advancedFilters, advancedValues, onAdvancedChange, onAdvancedReset, onReset, rightActions. primaryFilter / primaryValue / onPrimaryChange still accepted but auto-merged into the drawer (no longer inline). Legacy v2.0 props (filters / moreFilters) still supported during migration.
Review 8 follow-up: the bar carries the filter glyph, search, 1–2 commonly-used inline filter Select(s) (typically Status), grow-spacer, Reset, and an icon-only Filters button positioned rightmost. The button expands to icon + numeric badge when ≥1 filter is active. The inline filter(s) are ALSO surfaced inside the drawer (state shared) so users can edit from either entry point. All other dimensions (country, outlet, date, role…) live only inside the drawer. Single row, no wrap — the bar fits within one row at tablet width.
| Slot | Required | Detail |
|---|---|---|
| Filter icon | Always | Lucide ListFilter 18px · var(--forest) · decorative, no interaction |
| Search | Always | Input with leftIcon={Ic.search(16)} (icon INSIDE the input chrome, not floating beside it). Props: search + onSearch + searchPlaceholder. Width: flex: 1 1 180px; max-width: 260px. Placeholder must be contextual: "Search franchisees…", "Search outlets by name…" — never generic "Search…" |
| Select filters | As needed | One Select per primary dimension (Status, Country, etc.). minWidth: 140px. Render after search. Keep to 2–3 max — move extras to More. |
| More button | When >3 filter dimensions | Lucide SlidersHorizontal 14px + "More" label. Shows active count badge when extended filters are set. Opens FilterModal centred on page. |
| Reset | When filters active | Text button, var(--ink-2), 13px 700. Resets ALL filters including search and extended. Pass onReset prop. |
| rightActions | Optional | Action buttons (e.g., Export). Separated by a 1px divider. Render rightmost. |
| Property | Value |
|---|---|
| Container | White card · padding 14px 16px · position: sticky; top: 0; z-index: 5 · flexbox wrap |
| Height | 46px for all inputs/selects (matches DS Input component height) |
| Gap | 12px between all elements |
| Search width | flex: 1 1 180px; max-width: 260px — expands to fill space, cap at 260px |
| Select width | min-width: 140px; flex: 0 1 auto |
| Sticky behaviour | Sticks to top of scroll area as user scrolls through the table below |
| Rule | Detail |
|---|---|
| Search is mandatory | Every FilterBar must include a search input. Pass search + onSearch + searchPlaceholder. Placeholder must be contextual — "Search franchisees…", never generic "Search…". |
| Search icon placement | Search icon (Lucide Search 16px, var(--moss)) lives INSIDE the input chrome via Input leftIcon prop — same look as other filter fields. Never float the icon outside the input box. |
| Order of elements | Filter icon → Search → Select filters (by specificity, broadest first) → More → Reset → rightActions. Never reorder. |
| Primary vs. extended filters | Show 2–3 primary filter dimensions inline. When more dimensions exist, move them to the More button → FilterModal. Never add more than 3 inline selects — the bar becomes unreadable on tablet. |
| More filters modal | Use FilterModal component (field-per-row layout, Apply + Reset footer). Extended filters: date fields, numeric ranges, multi-select dimensions. Show active count badge on More button. |
| Never custom filter UI | Always use FilterBar + FilterModal. Never build a custom filter strip inline. |
| Sticky | FilterBar must always be sticky — stays visible as users scroll through long tables. |
| Reset scope | Reset clears ALL filters including search and all extended filters. Never reset only some. |
- Always include
SearchBaras the first filter element - Keep FilterBar sticky — it travels with the user as they scroll
- Use
Selectfor discrete dimensions (Status, Country) — never checkboxes - Show Reset only when at least one filter is active
- Don't omit search — even a table with only one filter dimension still needs SearchBar
- Don't use custom filter UI — always use the
FilterBarcomponent - Don't put FilterBar inside a card — it wraps tables at page level, not inside a data card
DateTimeFilter
Component: DateTimeFilter · File: shared-blocks.jsx (C-16b) · Props: value { mode, date, range, time }, onChange, country, todOptions. Surfaced both inline on the bar (FilterBar's optional dateTimeFilter prop, after the primaryFilter select, before the grow-spacer) and inside the advanced FilterDrawer (advancedFilters entry with kind:"datetime") — both edit the same caller state, so a change in either place updates the other.
A compact rounded-rect trigger field (var(--r) radius, 46px tall, 1.5px --mint border — matched to the inline .input-icon Select/search so it sits flush in the bar; not a pill) labelled "Today · Full day", "12 May · 09:00–14:00", "1–7 Jun" opens a popover with a SegToggle for Date (Single date ⇄ Date range — single uses one DateField defaulting to Today; range uses the from→to DateRangePicker mechanism). The Time section (Full day default ⇄ Time range — two optional <input type="time">; an empty bound = open-ended) renders only in single-date mode; switching to date-range hides the Time section and resets time to Full day so a stale time window can't keep filtering invisibly. The active country's time_of_day_range presets from Custom Options render as quick-fill chips that populate start+end (manual edits stay independently optional). Emits the default shape (Today · Full day) so callers can distinguish "default" from an explicit pick. Filters operate in the record's local timezone. Built from canonical SegToggle / DateField / MenuPopover / MenuItem — never hand-roll.
- Resolve
todOptionsin the caller —CUSTOM_OPTIONSwhereoption_type==='time_of_day_range'andscope===country || 'ALL' - Use
SegTogglefor both the date-mode and time-mode switches - Allow open-ended time bounds — start-only, end-only, or both
- Emit the default shape (Today · Full day) so default is distinguishable
- Surface the same state inline AND in the drawer (
kind:"datetime") when both entry points are wanted — share one caller state
- Don't hand-roll the popover or the toggles — use
MenuPopover/MenuItem/SegToggle - Don't make the trigger a pill — it's a rounded-rect field (
var(--r)) flush with the inline Select - Don't keep the Time section visible (or a set time window live) in date-range mode
- Don't hardcode time-of-day presets — they come from Custom Options per active country
- Don't fill the quick-fill chips with
--limeon the cream surface — forest/mint only
Detail Panel
Component: DetailDrawer · File: shared-blocks.jsx · Props: open, onClose, title, subtitle?, tabs?, actions?, children
| Franchisee | Outlets | Status |
|---|---|---|
| Thai Hots Group | 12 | Active |
| Manila Fresh Co. | 4 | Pending |
| Rule | Detail |
|---|---|
| Width | 700px canonical. CSS class .sub-drawer sets width: 700px — this wins over the component's width prop default of 560px. Use the component prop only to override to a narrower width for simple read-only panels. |
| Trigger | Opened by row click in a table. Never opened by a button labeled "View" — the row IS the trigger. |
| Sections vs. tabs | Use Tabs when there are 3+ distinct content groups (e.g., Overview · Outlets · Audit). Use plain sections (with FormSection or KeyValueGrid) when content is a single coherent group. |
| Header | Title: entity name. Subtitle: ID or secondary identifier (e.g., franch_ph_0042). Close button top-right — always present. No status pill in the header — put it in the body. |
| Action buttons | Bottom of drawer, right-aligned. Max 2 actions. Primary rightmost. Never a destructive action without a confirm step (ConfirmModal). |
| Scrim | Always shows behind the open drawer via .sub-scrim.open. Clicking the scrim closes the drawer (onClose). ESC key also closes — handled by the component automatically. |
| Inline alert in drawer | Place InlineAlert in the drawer body, below the header — never in the title or subtitle props. |
| Property | Value |
|---|---|
| Width | 700px (CSS canonical) · override via width prop for narrow panels only |
| Background | var(--cream) |
| Inner padding | 30px all sides (.sub-inner) |
| Gap between sections | 24px column gap |
| Title font | 700 22px/1.2 Nohemi · color var(--forest) |
| Subtitle font | 400 13px/1 Inter · color var(--ink-2) |
| Shadow | -8px 0 40px rgba(0,0,0,.22) |
| Slide animation | transform: translateX(100%) → translateX(0) · 350ms cubic-bezier(.2,.8,.2,1) |
| Focus trap | Built into component — Escape closes, Tab cycles within drawer |
- Use the CSS width (700px) as canonical — don't pass a
widthprop unless narrowing for a simple panel - Use
Tabsfor 3+ content groups;FormSection/KeyValueGridfor single-group content - Place action buttons at the bottom right, primary rightmost
- Show a
ConfirmModalbefore any destructive action - Editable Ops entities (e.g. Customers) use ONE drawer with a
modestate — read (KeyValueGrid + an Edit button) · edit (canonicalField/Input/Select/DateFieldinline, Cancel+Save) · create (same form blank, opened by aNew {Entity}PageHead button). Derived fields stay read-only.prototype-standards.md §35. - Read and edit render the same sections in the same order (RBAC may hide a section, never reorder): Identity first (carries the entity's
statusStatusPill) · Approval second for approval-workflow modules (Rewards/Promotions/Partnerships) — theapproval_statuspill plus submitted_by · approved_by · approved_at — GA changes approval here in edit mode · then the rest · derived sections (Activity) last. A datetime property is_fmtDateTimein read and<Input type="datetime-local">in edit (same granularity).03-ui convention 14.
- Don't reorder sections between read and edit, bury Status/Approval below the fold, or render a field date-only in read but datetime in edit — and never string-slice a timestamp or define a page-local
_fmtDateTime(§37/ CHECK 25) - Don't open a drawer from a "View" button — row click is the trigger
- Don't put a status pill or
InlineAlertin the drawer header props — both go in the body - Don't place form submit buttons in the
PageHeadactions when the form is inside the drawer - Don't build a separate full-page edit route for an Ops entity — Ops edits in-drawer (§35); full-page edit is the Network-entity pattern
- Don't nest drawers — one drawer at a time
Dashboard
Used on: GADashboard, CMDashboard, AMDashboard, OMDashboard · Sub-components: StatCard, ChartCard · File: shared-blocks.jsx
Every dashboard tier uses the same two building blocks — StatCard for KPI tiles and ChartCard for trend/mix charts — scoped to the role's data visibility. The layout is always: PageHead → InlineAlert (if needed) → StatCard grid → ChartCard(s). Never put charts above stat cards.
StatCard
Component: StatCard · Props: label, value, delta?, deltaDir?, sub?, deltaBasis?, icon?, onClick?
| Slot | Required | Detail |
|---|---|---|
| label | Yes | Short metric name. 700 11px Inter · uppercase · var(--mint). Use sentence case abbrevations: "GMV · last 30d", "Orders today". |
| value | Yes | Formatted string. 700 28px Nohemi · var(--forest). Include currency symbol, unit, or count in the string. Truncates with ellipsis if too long. |
| delta + deltaDir | No | Percentage change vs. prior period. ▲ + --forest for up; ▼ + --berry for down. Omit if no comparison period is meaningful. |
| sub | No | One-line metric definition or context. 11px Inter · var(--moss). Always include for non-obvious metrics (AOV, redemption %). |
| onClick | No | Makes the card interactive (drill-down). Shows pointer cursor. Only used on GA dashboard for country drill-down. |
| Property | Value |
|---|---|
| Container | White card · border 1.5px var(--line) · radius var(--r-lg) · padding 18px · flex column · gap 8px |
| Label font | 700 11px/1 Inter · uppercase · letter-spacing .1em · color var(--mint) |
| Value font | 700 28px/1.1 Nohemi · color var(--forest) · letter-spacing -.01em |
| Delta font | 700 13px/1 Inter · up → var(--forest) · down → var(--berry) |
| Sub font | 400 11px/1.3 Inter · color var(--moss) |
| Grid layout | repeat(4, minmax(0,1fr)) on GA/CM/AM (fixed 4) · repeat(auto-fill, minmax(160px,1fr)) on OM (8-tile wrapping) |
- Always pass a formatted string to
value— include currency symbol, "%" or unit - Include
subfor any metric whose name doesn't self-explain (AOV, redemption %) - Use
deltaDir="down"+ red color only for negative trends — don't make all deltas one color - Use
repeat(auto-fill, minmax(160px,1fr))for OM (8+ cards) to allow natural wrap
- Don't put raw numbers in
valuewithout formatting — "1820000" not "₱ 1.82M" - Don't use
onClickunless drilling down to a scoped view — it implies interactivity - Don't omit
deltajust because it's positive — show the trend, good or bad - Don't place StatCards below a ChartCard — always stat grid first, charts below
ChartCard
Component: ChartCard · Props: title, subtitle?, kind, data?, height?, actions? · Requires: Chart.js (loaded via CDN in shell)
| Slot | Required | Detail |
|---|---|---|
| title | Yes | Chart name. 700 16px Nohemi · var(--forest). Include time range: "Orders — last 30 days". |
| subtitle | No | Scope context: country name, filter label, date range. 400 12px Inter · var(--ink-2). |
| kind | Yes | "line" for time-series trends · "bar" for ranked comparisons (SKUs, outlets) · "donut" for part-of-whole (country mix, channel split). |
| data | No | Line/bar: number array. Donut: [{"{"}label, value, color{"}"}] array. Falls back to demo data if omitted. |
| height | No | Canvas height in px. Default 180. Use 200 for the primary trend chart; 180 for secondary charts side-by-side. |
| actions | No | Right-aligned slot for a period picker or small Btn. Renders top-right of the card header. |
| When to use | Kind | Example |
|---|---|---|
| Metric over time | line | Orders per day (last 30d), GMV trend (weekly) |
| Ranked list comparison | bar | Top SKUs by order count, top outlets by GMV |
| Part-of-whole composition | donut | Country GMV share, app vs. kiosk split |
| Property | Value |
|---|---|
| Container | White card · border 1.5px var(--line) · radius var(--r-lg) · padding 20px · flex column · gap 12px |
| Title font | 700 16px/1.2 Nohemi · color var(--forest) |
| Subtitle font | 400 12px/1 Inter · color var(--ink-2) |
| Line color | Border #006A56 (--forest) · fill gradient rgba(179,210,204,.35) → transparent |
| Bar color | #CEF580 (--lime) · border-radius 4px |
| Donut colors | Primary → --forest · Secondary → --lime · Tertiary → --orange · Quaternary → --mint |
| Axis labels | 11px Inter · color var(--ink-2) · no axis borders, grid lines only |
| Tooltip | Managed by Chart.js defaults — no custom tooltip component needed |
| Height (primary chart) | 200px — single chart spanning full width |
| Height (secondary charts) | 180px — two charts side-by-side in a 2-col grid |
- Place one full-width line chart first (primary trend), then secondary charts side-by-side below
- Include the time range in the chart
title("Orders — last 30 days") - Use
subtitleto set scope context (country, outlet, "All countries") - Pass
actionsfor a period picker when the chart is filterable
- Don't use
donutfor time-series — useline - Don't place ChartCards above StatCards — stat grid always comes first
- Don't pass raw Chart.js config directly — always use the
kindprop abstraction - Don't omit
subtitleon multi-country dashboards — scope context is mandatory
(markers = density · heat = L.heatLayer)
GeoMap requires the Leaflet + leaflet.heat CDN (loaded in the prototype shell). Static replica shown as a placeholder — live map needs the runtime.
Cell intensity = value ÷ row-set max, mapped to a --forest opacity ramp via color-mix on --cream-2 — never --lime (cells sit on a white card). Classes .matrix-heat* live in styles.css and auto-propagate.
dual-axis is a Chart.js canvas kind (two y-axes) — see shared-blocks.jsx ChartCard; not replicated statically here.
Avatar & File Upload
Components: Avatar, FileUpload · File: shared.jsx
Avatar renders a circular image (object-fit cover) or initials derived from a label on a forest disc. FileUpload composes an Avatar preview with Upload / Remove buttons for profile-image fields (B-60).
size: sm (28) · md (40, default) · lg (72). Initials = first letter of up to two words from label; image used when src is set.
When value is set, the preview shows the image and a ghost "Remove" button appears beside "Replace". Prototype-only: Upload emits a mock asset path (no real file IO).
Distinct from FileUpload (image-only). Use for CSV/XLSX/JSON imports where a canonical template should be downloadable first. Props: label, templateHref, value (filename|null), onChange(filename|null). Prototype: Upload emits a mock filename.
Wide modal (660 px) for editing legal policy content as rich text. Reuses .sub-scrim.open overlay pattern. The template prop preloads content when value is empty. Footer: secondary Cancel + primary Save. Component: PolicyEditorModal · File: shared-blocks.jsx.
Switch Role & Act-as
Components: SwitchRoleModal, ActAsBanner · File: shared-blocks.jsx · GA-only (B-61 · 03-ui §2)
A Global Admin can "act as" a lower role to view the console with that role's narrowed scope. SwitchRoleModal is a cascading scope picker (Role → Country → Area for AM/OM → Outlet for OM); ActAsBanner persists at the top of the shell while act-as is active.
Amber warn-styled banner so the operator never forgets the impersonated context. The RoleBadge reflects the active role; scope label = country · area · outlet as applicable.
| Role | Required scope |
|---|---|
| Country Manager | Country |
| Area Manager | Country → Area |
| Outlet Manager | Country → Area → Outlet |
"Act as" stays disabled until the required scope for the chosen role is fully selected. Emits onActAs({ role, scope }).
Tabs
Component: Tabs · File: shared-blocks.jsx · Props: tabs, active, onChange
| Property | Value |
|---|---|
| Tab font | 700 14px/1 Inter |
| Active color | var(--forest) · underline 3px solid var(--forest) · marginBottom: -1.5px (overlaps border) |
| Inactive color | var(--ink-2) · underline 3px solid transparent |
| Tab padding | 10px 14px |
| Count badge — active | bg var(--forest) · text var(--cream) · 700 11px Inter · 20px height · pill radius |
| Count badge — inactive | bg var(--mint-soft) · text var(--forest) |
| Strip border | 1.5px solid var(--line) at bottom of the tab container |
| Component call | <Tabs tabs={[{id, label, count?}]} active={activeId} onChange={setActive}/> |
| Rule | Detail |
|---|---|
| When to use Tabs | Use when a page has 3 or more distinct content groups that users switch between (e.g., Overview · Outlets · Users · Payment · Activity · Settings). Never use for 2 options — use a toggle or section header instead. |
| When NOT to use Tabs | If content is a single coherent group, use plain sections with FormSection or KeyValueGrid. Don't use tabs just to reduce scroll length. |
| Count badges | Only add a count badge when the number is meaningful and changes (e.g., number of outlets, users). Don't add counts for static or unknown quantities. |
| Active state | Exactly one tab active at a time. Driven by state — never by URL alone without setting active on mount. |
| Placement | Tabs strip sits directly below PageHead (or below InlineAlert if present), before the tab's content area. Never inside a card or drawer header. |
| Action scoping |
Page-level actions (apply to the whole entity, regardless of active tab) → PageHead.actions.Tab-specific actions (only relevant when this tab is active) → inside the tab content panel, as a header row above the tab's FilterBar or content. Example — Franchisee detail: · "Edit franchisee" + "Suspend" → PageHead.actions (always visible)· "Add Outlet" → inside the Outlets tab panel · "Invite User" → inside the Users tab panel Quick test: if switching to a different tab would make this button irrelevant, it belongs in the tab panel, not PageHead. |
| Prototype usage | Currently used in: Franchisee detail (GA-030), with tabs: Overview · Outlets · Users · Payment · Activity · Settings. See page-franchisees.jsx (FranchiseeDetail). |
- Use
Tabsfor 3+ distinct content groups on the same entity - Add count badges only when the count is live and meaningful
- Keep tab labels short (1–2 words) — use nouns, not verbs
- Put tab-specific actions (Add Outlet, Invite User) inside the tab content panel as a row above the content
- Put page-level actions (Edit, Suspend) in
PageHead.actions— they stay visible across all tabs
- Don't use Tabs for 2 options — use a section divider or toggle instead
- Don't nest Tabs inside tabs — only one level of tabs per page
- Don't put Tabs inside a
DetailDrawerheader — use thetabsprop onDetailDrawerinstead - Don't put tab-specific action buttons in
PageHead.actions— they belong inside the tab panel
Cross-role consistency
Iron-out rules v2.5 · enforce same data → same UI across GA / CM / AM / OM tiers · canonical source: prototypes/admin/COMPONENTS.md §6 + .claude/rules/prototype-standards.md §19–23
| Rule | Constraint |
|---|---|
| S1 · Pagination | Every standalone list page renders <TableFooter> with real page/pageSize state — even single-page demos. Embedded tables (drawers, "Recent Orders" sub-cards) are exempt. |
| S1 · Sortable | Every data column on every <Table> declares sortable: true. The shared Table renders sort glyphs + handles state automatically. Status pill columns and action-icon columns stay non-sortable. |
| S2 · Add-new | Place a primary New {Entity} button in PageHead.actions for every role × entity with create permission per 02-roles-permissions.md. Use the labelled form, not bare +. OM has no entity-creation buttons. |
S3 · StatCard sub | Every <StatCard> passes sub= — even simple counts. The slot reserves minHeight: 29 regardless; missing sub leaves visible empty space. |
| S4 · Contact | Email / phone values render as <value> + .contact-icon-btn (Mail/Phone Lucide icon). Use _emailLink(v, {muted}) / _phoneLink(v, {muted}) in helpers.jsx. Never add separate "Email customer" / "Message" / "Contact" action buttons. |
| S1c · Date-time display | Display, not filter (distinct from the DateTimeFilter, §04c). One canonical format YYYY-MM-DD HH:MM <TZ> (e.g. 2026-06-03 09:14 SGT), byte-identical in table Date/Timestamp columns and detail views. Helpers in helpers.jsx — never re-format inline or define a page-local copy (CHECK 25). Two cases: (a) event instants (approved_at, created_at, redeemed_at — stored UTC) use _fmtDateTime(iso, tz), shifted to the tz frame = the viewer's scope (_scopeTz(scope); GA-global → the HQ default _gaTz()). (b) schedule windows (start_date/end_date — a naive local wall-clock, the same value the datetime-local editor holds) use _fmtLocalDateTime(naive, tz) — same format, no UTC shift (so read = edit). Window tz = the record's single market, or _gaTz() for a multi-market record. _gaTz() = the GA/HQ default tz, GA-set via the hq_timezone scalar Custom Option (default SG). Date-only / time-only column variants: _fmtDate / _fmtTime (no TZ suffix). |
| S6 · StatPanel (v2.6) | Every StatCard cluster lives inside <StatPanel> — filter strip + grid in one card surface. Universal default mode: "range", range: 7D. Mode toggle (Date / Range) always visible. Delta gated on statPanelShowDelta(state); charts hidden when mode === "date". Snapshot variant (no state) for status counts. A delta-bearing StatCard MAY pass deltaBasis={statPanelDeltaBasis(state)} to show the comparison basis on a 2nd sub line with a muted-moss triangle marker (never lime). Helpers statPanelShowDelta / statPanelCaption / statPanelDeltaBasis are the single source of truth — never reimplement. |
| §25 · DS-first workflow | Detect → Check → If missing, extend the DS first → Then consume. Never inline a one-off in a page-*.jsx module or in an HTML replica when a canonical component / class doesn't exist — STOP, ship the DS change first (six required artifacts: shared-blocks.jsx → styles.css → admin-ui-standards.html replica → COMPONENTS.md → prototype-standards.md if new constraint → audit). HTML replicas reuse existing canonical classes (.sc, .sp-*, .tbl-*) — never inline custom styles. Full rule + workflow in prototype-standards.md §25. |
| §27 · Drift review (compounding loop) | When inconsistency is reported (user flag / DS SYNC hook / new audit violation): run the 5-step workflow — Inventory → Five-whys → Categorize (missing-variant / missing-rule / unenforced-rule / ambiguous-canonical / skill-gap / hook-blind-spot) → Propose → Land. Required closing artifacts: DRIFT_LOG entry · audit-script CHECK (unless waived) · rule update if new constraint. Each review compounds the audit script so the same class never recurs. Search DRIFT_LOG.md before adding any new pattern. Full workflow in prototype-standards.md §27. |
Same pattern in table cells, KeyValueGrid values, and DetailDrawer bodies — one affordance per channel, in one place.
| Element | Spec |
|---|---|
| Trigger | <a href="mailto:…"> or <a href="tel:…"> |
| Class | .contact-icon-btn (in styles.css) |
| Size | 24×24 round, icon 14px |
| Default state | icon var(--forest), transparent background |
| Hover | background var(--mint-soft), icon var(--forest-deep) |
| Spacing | 6px gap from value |
| Click | e.stopPropagation() required (so it doesn't open the row's drawer) |
- Use
_emailLink(email)/_phoneLink(phone)helpers inhelpers.jsx - Pass
{muted: true}for table cells, omit for KeyValueGrid items - Use the same pattern in tables, drawers, and detail pages — one affordance per channel
- Add separate "Email customer" / "Message customer" / "Contact" buttons below the row or in drawer actions
- Render email/phone as plain text without the inline icon
- Use a ghost
<Btn>withonClick+window.location.href— use<a href>instead
Display, not filter — distinct from the DateTimeFilter (§04c), which slices records. The same timestamp string appears in table Date/Timestamp columns and in detail views, byte-identical, because both call _fmtDateTime(iso, tz) (helpers.jsx). Record-local timezone (outlet → country, convention 7), never the viewer's.
| Variant | Helper | Output |
|---|---|---|
| Date + time (canonical) | _fmtDateTime(iso, tz) | 2026-06-03 09:14 SGT |
| Date only (column) | _fmtDate(iso, tz) | 2026-06-03 (no TZ suffix) |
| Time only (column) | _fmtTime(iso, tz) | 09:14 (no TZ suffix) |
| Element | Spec |
|---|---|
| Format | YYYY-MM-DD HH:MM <TZ> — 24-hour, zero-padded |
| Timezone | Record-local (outlet → country frame), convention 7. SG→SGT(+8) · PH→PHT(+8) · ID→WIB(+7) · TH→ICT(+7); fallback UTC |
| Table === detail | Both surfaces call the same _fmtDateTime — never re-format inline |
| Parsing | Naive ISO (no Z/offset) treated as UTC; falsy → —; unparseable → raw input |
| First applied | Wallet (SG-only) — passes "SG" in both the table Date column and the detail-drawer Date field |
- Call
_fmtDateTime(iso, tz)in both the table cell and the detail view - Pass the record's country frame as
tz(Wallet →"SG") - Use
_fmtDate/_fmtTimeonly when a column needs date- or time-only
- Re-format inline (
iso.slice(0,10), per-module_sgtDisplay,_ISO_DATE(ts)) — drift between table and detail - Omit the TZ suffix on the canonical date+time form
- Render in the viewer's browser timezone — always the record's local frame
Uppercase section header (T.label tokens) for the no-tabs dashboard's ~30 section headers. Optional sub (muted helper) + right slot (link / count). Reuses the .sec-label class shared with the React source — no inline repetition.
Universal default: mode: "range", range: 7D. Mode toggle always rendered. Charts hide in date mode.
| Element | Spec |
|---|---|
| Default state | STAT_PANEL_DEFAULT_STATE = { mode: "range", range: { preset: "7D" }, date: { preset: "today" } } |
| Mode toggle | SegToggle (.seg-toggle / .seg-btn). Always visible. Active button: forest bg + cream text. |
| Date mode presets | Today · Yesterday · Pick date (custom <input type="date">). No delta on cards. Charts hidden. |
| Range mode presets | 7D · 30D · 90D · Quarter (QTD, ~90d) · Year (YEAR, 365d) · MTD · YTD · Custom (from→to date inputs). Delta shown. Charts visible. A dashboard may seed a per-role initial preset (e.g. GA → Quarter) at the call-site without mutating STAT_PANEL_DEFAULT_STATE (Reset still returns to 7D). |
| Header icon | Sliders (Lucide). Distinct from Filter funnel which is reserved for tabular FilterBar. |
| Snapshot variant | Omit state + onChange → renders just the card grid in panel surface (no header). For PaymentStatus, Inbox, FranchiseeDetail. |
| Delta gating | Always delta={statPanelShowDelta(state) ? "+5%" : null}. Never hand-write delta unconditionally. |
| Caption | Append ${'`${captionBasis}`'} from statPanelCaption(state) for range-bound metrics. Static caption for entity-fixed metrics. |
| Charts | Wrap <ChartCard> in {state.mode === "range" && …}. Single-day data isn't a trend. |
- Use
useState({ ...STAT_PANEL_DEFAULT_STATE })for state - Gate
delta=onstatPanelShowDelta(state) - Use
scopeSelectprop for country/area selectors in the header - Hide trend ChartCards in date mode
- Render bare
<div style={{ display: "grid" }}>{statCards}</div>outside a StatPanel - Hand-write
delta="+5%"unconditionally — breaks date-mode contract - Reimplement the date/range branching logic — use the helpers
- Use the Filter funnel icon — that's reserved for tabular
FilterBar
IntegrationBlock & ManagerSection
Conventions 10 & 11 · shared-blocks.jsx · cross-entity (Country · Outlet · Area) · spec 03-ui.md Table 2/3
ONE shared renderer for the Integration tab, consumed by CountryDetail & OutletDetail — never a fork. Provider shows inline beside the StatusPill; DX-configured detail nests below, gated on connected. On Outlet, providers + commercial values are inherited from the country (read via join); only pos_store_id is outlet-owned (editable in create/edit, passed via the posStoreIdField slot).
Caller-owned surround (not part of the component): the <FormSection> wrapper, the GA-only "Run trial transaction" button, and the go-live InlineAlert. The block renders the two-card grid only.
Managers are Users (scope field: Country→franchise_id · Outlet→outlet_ids[] · Area→area_id) — a SECOND entry point into user assignment, never a manager_id on the entity. Four modes:
ListEditor
GA-curated editable list section · Source: shared-blocks.jsx · CSS: .list-editor / .le-row / .le-tag
One labelled list of small entries (order concepts, dining modes, custom-option presets). Each row exposes inline editable cells per fields schema (kind ∈ text/number/time/toggle), a hover-only remove icon, and an optional cap that hides Add + shows a “max N” hint when reached.
Inherited rows (country overrides global) render greyed / read-only with an inherited tag + a secondary Override action (§9 — ghost is navigation-only, never for actions) that promotes the row to a country-specific editable row.
ApprovalCard
Component: ApprovalCard · File: shared-blocks.jsx · Props: status, kind, message, canApprove, canWithdraw, onApprove, onReject(reason), onWithdraw
canApprove) — Approve (primary, rightmost) + Reject (secondary → reason modal)canWithdraw) — Withdraw (secondary) while still pending- Sits directly under the detail title — inside a DetailDrawer body or at the top of a full-page detail. White card surface (
.approval-card), never an InlineAlert. - Presentational only. The page computes
canApprove/canWithdrawfrom RBAC and passes them in. The card never reads roles. - Approver gets Approve (primary forest, rightmost) + Reject (secondary → internal reason modal; reason required). The submitting AM/CM gets Withdraw while pending.
- Renders only while pending (status starts with
pending). For draft / approved / rejected the card returnsnull(hidden) — those states read from the StatusPill + the Approval audit section. status+kindforward straight to StatusPill — use the approval-bearing kind for the surface (reward·partner·promo_approval·refund).- Applies to Promotions · Rewards · Partnerships · Refunds · Outlet-hours. Not Legal policy (uses PolicyEditorModal).
DeleteSection
Component: DeleteSection · File: shared-blocks.jsx · Gate: _canDelete(role, {status, isReferenced, rbacOk}) (helpers.jsx) · Props: entityLabel, gate, soft, note, confirmBody, onDelete
- The only Delete affordance — Network entities in the detail Settings danger zone, Ops entities in the last section of the edit/create DetailDrawer. Owns its own
ConfirmModal. - Referenced / in use → blocked (any status); unlink first.
draft(never launched, unreferenced) → HARD-delete by the creating tier (rbacOk).active/inactive(has been live, unreferenced) → SOFT-delete, GA only (the special condition). Pass the verdict via_canDelete(...)— never inline the policy.softprop =status !== "draft"(draft hard · active/inactive soft decommission). Enforced byaudit-prototype.shCHECK 29.