Skip to content

Honeyframe Data Integration ​

Read-only BI data source for Hermina 360 dashboards.

Official API contract: https://docs.honeyframe.io/webapp-data-apiBase URL: https://hospital.hubstudio.idMinimum version: Platform v0.2.62+ Token: set in .env.local as VITE_HF_TOKEN — never commit to source control


Quick Start ​

bash
# .env.local (not committed) — DEV ONLY, see Authentication below
VITE_HF_TOKEN=hf_xxxx...
ts
import { useWebappConfig, usePageBatch } from '@/hooks';
import { useHoneyframeStore } from '@/stores';

// 1. Load config (cached)
useWebappConfig('hermina360');
const app = useHoneyframeStore((s) => s.apps['hermina360']);

// 2. Find page and card refs
const page = app?.config.pages.find((p) => p.key === 'executive');
const cardRefs = (page?.card_refs ?? []).filter(
  (b): b is HFCardRef => b.kind === undefined || b.kind === 'card',
);

// 3. Fetch batch with optional params
const { data: batch } = usePageBatch('executive', cardRefs, app?.asset_id, {
  period_start: '2025-06-01',
  period_end: '2025-06-30',
});

// 4. Read results — ALWAYS check error first
const cardResult = batch?.[String(cardId)];
if (cardResult?.error) console.error(cardResult.error);
const rows = cardResult?.rows ?? [];

Webapps ​

App Keyasset_idDescriptionDashboards
hermina3601200Main executive + operational244, 280, 291–299
hermina360_financial1207CFO / financial (richer data)284, 285, 286, 287

Authentication ​

The app logs the user into Honeyframe and uses the returned JWT for every call.

Login ​

POST {HF_BASE}/api/auth/login
Body: { "username": "...", "password": "..." }

200 → { "access_token": "...", "token_type": "bearer", "must_reset_password": false }
401 → bad credentials

HF_BASE is the hfApi base — /hf-api in dev (Vite proxy) and in production (nginx proxy). useAuthStore.login() (src/stores/useAuthStore.ts) calls it, persists access_token to localStorage under hermina360-auth, and exposes logout() to clear it. must_reset_password: true raises a sticky notice after login and is kept on the store as mustResetPassword.

All subsequent requests carry that JWT:

Authorization: Bearer <access_token>

A hfApi request interceptor injects it; a response interceptor clears auth and redirects to /login on any 401 except the login call itself. PrivateRoute gates on the presence of that token.

VITE_HF_TOKEN is a dev-only fallback ​

When no user is logged in, the interceptor falls back to VITE_HF_TOKEN only if import.meta.env.DEV. That branch is statically false in npm run build, so the PAT is dropped from the production bundle. Never set VITE_HF_TOKEN in a production build — a token baked into the bundle is handed to every visitor.

Token Permissions ​

  • project.view — config, block execute, filter options
  • dashboard.view — card execute
  • Recommended scope ceiling: ["project.view", "dashboard.view"]

Failure Modes ​

StatusMeaningAction
401Bad, revoked, or expired tokenTerminal — do not retry
403Permission denied or token scoped to different org/projectTerminal — do not retry
404Not found or not visible (deliberately indistinguishable)Check key/ID
429Rate limitedBack off and retry

Platform Tier Note: Unlicensed orgs receive 403 with license_required body. App tier does not enforce this.


Endpoints ​

#MethodPathPurposeLimits
1GET/api/webapps/{key}Fetch config (pages, nav, card_refs)Cache — changes only on republish
2POST/api/dashboards/{id}/cards/{card_id}/execute?asset_id=NExecute single card1000 rows, 30s timeout
3POST/api/dashboards/{id}/cards/execute-batch?asset_id=NRun multiple cards per dashboard1000 rows per card, 30s timeout
4POST/api/webapps/{key}/pages/{pageKey}/blocks/{idx}/executeRun HTML/code blocks with data_sql5000 rows, 8s timeout
5POST/api/webapps/preview-filter-optionsResolve options_sql to dropdown values5000 rows, 8s timeout

Webapp Config (Endpoint 1) ​

Response contains pages, nav, card_refs, filters, parameters.

Block shapes in card_refs:

KindDescriptionExecute via
"card" or absentSQL-backed cardEndpoint 2/3
"code"Code blockEndpoint 4
"html"Sandboxed markup; if has data_sqlEndpoint 4
"html" (no data_sql)Static markupNo data call needed

Card block fields (server-hydrated, usable before execution):

  • card_type: bar | line | pie | donut | kpi | text
  • card_title: Display title
  • card_config: Chart config (xField, yField, nameField, valueField, etc.)
  • card_config_overrides: Apply on top of card_config when present

Block indexing: block_idx = position in card_refs array. Public redactor blanks hidden blocks in place (indexes stable). Never filter array before deriving index.

Execute Single Card (Endpoint 2) ​

POST /api/dashboards/{dashboard_id}/cards/{card_id}/execute?asset_id={asset_id}
Body: { "params": { "key": "value" } }

Execute Batch (Endpoint 3 — main data path) ​

POST /api/dashboards/{dashboardId}/cards/execute-batch?asset_id={assetId}
Body: { "card_ids": [number], "params": { "key": "value" } }

Response: Record<string, CardResult> — keyed by card_id (string, not integer).

ts
type CardResult = {
  card_id: number;
  columns: string[];
  rows: Record<string, unknown>[];
  row_count: number;
  execution_ms: number;
  error: string | null;       // ← ALWAYS CHECK THIS
  bound_params?: string[];    // confirms which params each card responds to
};

Row limit: 1000 rows appended as LIMIT on connector-backed and parameterless warehouse paths. Parameterized warehouse queries (common for card execution with params) run without appended LIMIT — only 30s timeout bounds them. Size clients accordingly.

Critical: Failed queries return HTTP 200 with error set and rows: []. Always check error before reading rows.

Block Execute (Endpoint 4) ​

POST /api/webapps/{key}/pages/{pageKey}/blocks/{blockIdx}/execute
Body: { "filters": { "filter_name": "value" } }
ts
type BlockResult = {
  columns: string[];
  rows: Record<string, unknown>[];
  row_count: number;
  execution_ms: number;
  error: string | null;
  truncated: boolean;         // true if exceeded 5000-row limit — surface to user
};

blockIdx = index in card_refs array (before any filtering). Max 5000 rows, 8s timeout. Honor truncated flag.

Filter Options (Endpoint 5) ​

POST /api/webapps/preview-filter-options
Body: { "sql": "SELECT code AS value, name AS label FROM ..." }
ts
type FilterOption = {
  value: string | number;
  label: string;
  code?: string;              // present only when query returns it
};

Send options_sql from config verbatim — modified SQL returns 403. Read-only tokens cannot run ad-hoc SQL.

Anonymous/Public Endpoints ​

MethodPathNotes
GET/api/public/webapps/{key}?token=SQL redacted, PII masked, no filter options
POST/api/public/webapps/{key}/cards/{dashboard_id}/{card_id}/execute?token=No auth header needed

Public paths redact data_sql, mask PII columns, and blank public_hidden blocks in place (indexes stable).


Filter Parameter Contract ​

Filter state travels as ONE flat dict in the params field of execute-batch. Build it with buildFilterParams() (src/lib/filterParams.ts) — do not assemble the keys by hand.

1. Period (filters.period, type preset_date_range) ​

The config's params object names the bind keys; on both hermina360 apps they are:

jsonc
{ "period_start": "2026-07-01", "period_end": "2026-07-31", "period_label": "Bln Lalu" }

period_label is the preset key, or 'custom' for a hand-picked range.

2. Select / pill filters (branch_select, pill_multi) ​

The config declares param (e.g. branch_id, kelas, regional). The card SQL binds the COMPANION key <param>_codes — a CSV of the selected options' code values (pill options with no code use their value):

sql
(COALESCE(:branch_id_codes,'ALL') = 'ALL'
 OR afya_code = ANY(string_to_array(:branch_id_codes, ',')))
Selection<param><param>_codes
nothingemptyValue ('ALL')emptyValue ('ALL')
onethe raw valuethat option's code
manyarray of raw valuescodes joined with ,

kelas_codes matches criteria_band (A/B/C/D); regional_codes matches regional. Never send branch_keys — server-side cards COALESCE it away.

ts
const params = buildFilterParams({
  period: { start: '2026-07-01', end: '2026-07-31', label: 'Bln Lalu', keys: periodKeys },
  selects: [
    { param: 'branch_id', options: branchOptions, selected: ['12', '31'] },
    { param: 'kelas', options: kelasOptions, selected: kelas === 'Semua' ? [] : [kelas], mode: 'single' },
  ],
});
// → { period_start, period_end, period_label,
//     branch_id: ['12','31'], branch_id_codes: 'RSHB,RSHK',
//     kelas: 'ALL', kelas_codes: 'ALL' }

Bind names and option codes are read from the app config at runtime (resolvePeriodKeys, resolveSelectFilter); the page's hardcoded pill values are only a fallback for when the config does not declare that filter.

3. Card badges (bound_params) ​

Each batch result carries bound_params — the bind names that card's SQL actually references. deriveCardBadge() (src/lib/cardBadges.ts) turns that into a chip:

  • a branch-scoping filter is set AND neither <param> nor <param>_codes is in bound_params → "Tidak per cabang"
  • else the period moved off the default AND none of period_start / period_end / period_label is in bound_params → "Tidak per periode"

Branch wins; the two never stack. The chip only renders once the card has loaded cleanly (no error, not loading) and the server reported bound_params — an absent bound_params badges nothing rather than guessing.


Integration Pattern ​

Standard page pattern used across all dashboard pages:

useWebappConfig → find page by key → filter card_refs (kind=card) → usePageBatch(pageKey, cardRefs, asset_id, params?)

executePageBatch groups cards by dashboard_id and fires parallel batches.

Per-page block loop:

ts
for (const [idx, blk] of page.card_refs.entries()) {
  if (blk.kind === 'code' || (blk.kind === 'html' && blk.data_sql)) {
    await executeBlock(key, page.key, idx, filters);
  } else if (blk.kind === undefined || blk.kind === 'card') {
    // Collect by dashboard_id, batch per dashboard
  } else if (blk.kind === 'html') {
    // Static markup—render blk.html, no data call
  }
}

hermina360 — Card Inventory (v132, 34 pages) ​

executive (dashboard 244) ​

Card IDTitleNotes
2016Pasien Unik
2017Dokter Aktif
2026Tingkat Pembatalan
2010Rawat jalan totalKunjungan, per hari
2014Admisi Rawat Inap (Jaringan)Rawat Inap (Episode), NO Hari Rawat field
2039Operasi · JaringanNOT period-bound (YTD always)
2037Lahir · JaringanNOT period-bound (YTD always)
2225Tren Kinerja Jaringan — 6 Bulan
2226Prioritas Tindakan Direksi
2246Bulan Berjalan vs Bulan LaluHas Δ MoM (%) per metric
2002Padma vs Kamala — Bauran Layanan

operational (dashboard 244) — superset of executive ​

All executive cards plus:

Card IDTitleNotes
2312Fisioterapi
2313KTK — Tumbuh Kembang
2363Tren Operasional Jaringan — 12 Bulan
2362Pembatalan per Cabang
2364Ringkasan Operasional per Cabang
2365BOR BulananBOR % trend, last value = current BOR
2366Volume Operasi Bulanan — Jaringan

financial (dashboard 244) ​

Card IDTitleNotes
2669Pendapatan (Bruto) — JaringanGross revenue (≠ net from 2598)
2670Revenue Rawat InapGross, period-bound
2671Revenue Rawat JalanGross, period-bound
2672Pendapatan IGDGross, period-bound
2675Pendapatan Bruto per Regional (I–VI)Regional bar chart data
2676Yield per EpisodeARPIP/ARPOP per episode type
2227Penerimaan / Collections
2228Klaim (Settlement)
2229Bayar Langsung (Payment)
2230Deposit Diterima
2231Komposisi Penerimaan (Metode)
2357Akrual vs Collections per Cabang
2358Catatan Data Keuangan
2359Tren Penerimaan & Deposit — 12 Bulan
2360Penerimaan per Cabang (Top 15)
2361Komposisi Kunjungan BPJS vs Non-BPJS
2387Tren Pendapatan Bruto (GL) — Padma vs Kamala
2388Pendapatan Akrual per Penjamin (GL)
2389Pendapatan Akrual per Cabang (GL) — Top 15

Cards 4425–4442 (18 new cards added in v132, dashboard 280).

financial-gl (dashboard 280) ​

GL-based P&L data. Cards 3130–3176 covering:

  • P&L Summary (3130–3137): Revenue RS, COGS, GP, Margin, Opex, EBIT
  • Revenue breakdown (3138–3149): IP/OP/IGD, BPJS vs Non-BPJS, per cabang
  • COGS structure (3151–3161): drug, salary, per line item, ratio trends
  • Opex (3163–3172): salary BUA, insurance, per line item
  • Profitability (3174–3176): drug revenue

ringkasan-eksekutif (dashboard 244) ​

Card IDTitleNotes
2598Kinerja Keuangan HEAL (Metrik × Periode)Revenue, EBITDA, NP, NPM, Padma Mix — NET revenue
2581Laporan Laba Rugi (P&L Cascade)Full P&L table
2574Prioritas Tindakan DireksiCabang, Kls, EBITDA Mgn %, Net Profit, NPM %, % Padma
2572Komposisi Revenue — Padma vs Kamala
2599Pendapatan Net per Segmen (GL Live)
2665–2668Metric heroes (Revenue, EBITDA, NP, GP)Trend data, 12-month series

tren-periodik (dashboard 244) ​

Card IDTitleNotes
2665Revenue Total — metric_heroMonthly series with Δ%
2666EBITDA — metric_hero
2667Net Profit — metric_hero
2668Gross Profit — metric_hero
2582Tren — Revenue Total · RS · Non-RS
2583Tren — EBITDA · NP · GP
2584Tren — Margin: EBITDA · NPM · GPM (%)
2585Tren — Revenue Padma vs Kamala
2586Tren — Biaya: Obat · TK · Pemeliharaan · Utilitas
2587Tren — Rasio Biaya / Revenue (%)

clinical (dashboard 244) ​

Card IDTitle
2376LOS Rata-rata Rawat Inap
2025Hari rawat ICU
2028Pemakaian ventilator
2377Komposisi Encounter (RJ/RI/Darurat)
2378Top 12 Diagnosa (ICD-10)
2365BOR Bulanan
2441Top 10 Diagnosa — Jaringan AFYA
2442ALOS per RS — Jaringan AFYA

pharmacy (dashboard 244) ​

Card IDTitle
2328Belanja Farmasi & Supply
2420Pendapatan Obat · Jaringan
2329SKU Diterima
2330Vendor Aktif
2331Batch ≤90 Hari Kadaluarsa
2419Analisis ABC — Pareto Pengadaan
2332Top Item — Belanja Procurement
2334Belanja Procurement per Bulan
2335Batch Mendekati Kadaluarsa

branches (dashboard 244) ​

Card IDTitle
2421Statistik Cabang — Cabang vs Region vs Jaringan
2340Ranking Cabang — Volume Kunjungan
2422Ranking — Penerimaan (Rp juta)
2424Semua Metrik — 54 Cabang
2336Branch Explorer — Semua Cabang

branch-focus (dashboard 244) ​

Card IDTitle
2606Perbandingan Cabang header
2603Perbandingan Revenue per Cabang — Bulanan
2605Profitabilitas per Cabang — Bulan Terkini
2607Struktur Biaya per Cabang — Bulan Terkini

likuiditas (dashboard 291) ​

Card IDTitle
4188Kas & Setara Kas
4189Piutang Usaha (AR)
4190Persediaan
4191Utang Usaha (Trade AP)
4192CCC (Hari)
4193CCC Komposit — DSO + DIO − DPO
4194Piutang per Penjamin
4195Tren Modal Kerja — 6 Bulan
4196AR Aging — Piutang Pasien Pribadi
4197Umur Utang Usaha (AP Aging)
4198AR Days (DSO) — Top 15 Cabang
4386Likuiditas — additional metric

heatmap-kpi (dashboard 297) ​

Card IDTitle
4182Heatmap KPI Cabang — 8 Indikator
4183Ranking Worst-first — Komposit KPI

lini-layanan (dashboard 293) ​

Cards 4339–4364 covering Poliklinik, IGD, Rawat Inap, Kelahiran metrics with trends and worst-first rankings.

kinerja-penjamin (dashboard 295) ​

Cards 4397–4405: Asuransi, Perusahaan, BPJS TK, OOP, target realization, payer mix.

pelayanan-jkn (dashboard 296) ​

Cards 4406–4415: BPJS revenue/volume, ARPIP/ARPOP BPJS, selisih koding.

vaksinasi (dashboard 298) ​

Cards 4170–4180: Vaccine doses, antigen trends, funnel, projection.

varians-biaya (dashboard 294) ​

Cards 4365–4385: Cost ratios, worst-first rankings, honor dokter.

retensi-pasien (dashboard 299) ​

Cards 4416–4424: Retention cohorts, new vs returning patients.

wasdal (dashboards 244, 292) ​

Card IDTitle
2236Wasdal Prioritas — Scorecard 54 Cabang
4199Wasdal Prioritas — Heatmap 8 Indikator
4200Ranking Worst-first — Komposit Wasdal

review-heal (dashboard 244) ​

HEAL network review. 12 cards, shares many with ringkasan-eksekutif and tren-periodik.

Card IDTitle
2562Review HEAL — Summary
2564Review HEAL — Detail
2572Komposisi Revenue — Padma vs Kamala
2574Prioritas Tindakan Direksi
2581Laporan Laba Rugi (P&L Cascade)
2599Pendapatan Net per Segmen (GL Live)
2656Metric Hero — Revenue
2657Metric Hero — EBITDA
2665Revenue Total — metric_hero
2666EBITDA — metric_hero
2667Net Profit — metric_hero
2668Gross Profit — metric_hero

review-rs (dashboard 244) ​

Per-hospital review. 8 cards.

Card IDTitle
2563Review per RS — Detail (names NOT masked)
2564Review HEAL — Detail
2597Revenue per RS
2656Metric Hero — Revenue
2665Revenue Total — metric_hero
2666EBITDA — metric_hero
2667Net Profit — metric_hero
2668Gross Profit — metric_hero

dwh-gross (dashboard 244) ​

DWH gross revenue analysis. 9 cards (2589–2597).

Card IDTitle
2589–2596DWH Gross Revenue breakdowns
2597Revenue per RS

patients (dashboard 244) ​

Patient analytics. 13 cards (7 new: 4443–4450).

Card IDTitle
2002Padma vs Kamala — Bauran Layanan
2010Rawat jalan total
2011Rawat Jalan — Detail
2014Admisi Rawat Inap (Jaringan)
2015Rawat Inap — Detail
4443–4450Patient analytics (new in v132)

Other pages ​

  • doctors (244): 2234 — Doctor listing
  • specialists (244): 2235 — Specialist listing
  • catalog (244): 2239 — Catalog/reference data
  • builder (244): 2240 — Dashboard builder data
  • wiki (244): 2241 — Wiki content
  • episode (no dashboard): 1 code block — Episode analysis
  • sdm (244): 2234, 2380–2383 + 2 code blocks — Doctor headcount, specialization distribution
  • diagnostic (244): 2031–2035, 2232, 2417 — Imaging modality volumes
  • staffing (244): 2042–2043 — SP1/SP2 doctor counts
  • assets (244): 2004–2006, 2386, 2431–2433 — Bed inventory
  • data-trust (244): 2485–2497 — Patient identity quality metrics

hermina360_financial — Card & Block Inventory ​

ringkasan (dashboard 284) ​

HTML Blocks (block-execute):

BlockContentKey Columns
block[0]Data freshnesskind, a (timestamp), b (business date), n (count)
block[1]P&L monthly trendkind=m: label (YYYY-MM), a (revenue), b, c, d
block[2]Revenue breakdown (exact)rev_ip, ip_p, ip_k, rev_op, op_p, op_k, rev_igd, d (IP days), v (OP visits) — all in Rp
block[3]P&L totalsrev, ebitda, laba, gp, padma, kamala, p_* (previous period)
block[4]Volume & BOR based, v, bed_days, beds — BOR = d/bed_days×100
block[8]Intra-month previewDaily billing, NOT comparable with GL data

Cards:

Card IDTitleKey Fields
3786Rincian Lini Layanan × KelasMetrik, Total/Blended, Padma, Kamala
3787Revenue per RegionalRegional, Revenue (Rp M)
3788Prioritas Direksi (richer than 2574)Cabang, Reg, Kelas, Revenue (Rp M), EBITDA Mgn %, NPM %, BOR* %, % Padma, Quick Assessment

review-konsolidasi (dashboard 285) ​

Card IDTitle
3790Revenue Padma
3791Revenue Kamala
3792Rasio Obat
3793Rasio Tenaga Kerja
3794Tabel Deret Waktu — YTD · Avg · M-2 · M-1 · M-0
3795Revenue Total — 12 Bulan (anotasi musiman)
3796EBITDA & Margin — 12 Bulan
3797Revenue Padma vs Kamala — 12 Bulan
3798Rasio 4 Penggerak Biaya — 12 Bulan
3799Ranking Biaya — bulan berjalan

review-rs (dashboard 286) ​

Card IDTitle
3801Peta Panas — seluruh RS × indikator
3802EBITDA Margin — 15 RS terburuk
3803Revenue per RS — Top 15
3804Waterfall Δ EBITDA MoM
3805Rollup per Regional
3806Distribusi Quick Assessment

tren-bulanan (dashboard 287) ​

Card IDTitle
3808Revenue Bulanan — Total · Padma · Kamala
3809Revenue Bulanan — Rawat Inap vs Rawat Jalan
3810Hari Rawat Inap Bulanan — Padma vs Kamala
3811ARPIP Bulanan — Padma vs Kamala
3812Growth MoM Revenue (%)
3813Penguraian Pertumbuhan IP — Efek Volume vs Efek Tarif
3814CMGR YTD — Revenue & Volume per RS

Data Gotchas ​

  1. HTTP 200 with error: Failed queries return 200 with error set + empty rows. Always check error before consuming data.
  2. Gross vs Net revenue: Cards 2669–2672 return gross revenue; card 2598 returns net revenue. Different legitimate metrics.
  3. Card 2014 has no Hari Rawat: Only Rawat Inap (Episode). Use hermina360_financial block[2] for real IP days.
  4. Cards 2039/2037 not period-bound: Surgery and birth cards return YTD data regardless of period params.
  5. Hospital names masked in dashboards 293/294/297 — branch_code available in 297.
  6. Card 2563 (review-rs): Names NOT masked, Thn = founding year.
  7. bound_params in execute-batch response confirms which params each card responds to.
  8. block_idx is array index: Use the index in card_refs array BEFORE filtering by kind.
  9. options_sql is read-only: Send verbatim from config — modified SQL returns 403. Read-only tokens cannot run ad-hoc SQL.
  10. Padma/Kamala split: hermina360 card 2598 only has overall Padma Mix % (approximation). hermina360_financial block[2] has exact per-service-line split.
  11. Row cap not guaranteed: 1000-row LIMIT is appended on connector-backed paths, but parameterized warehouse queries run without it — only 30s timeout bounds them.
  12. card_config_overrides: Apply on top of card_config when present in card_refs.

Integration Checklist ​

  1. Fetch config once per page load; cache it (changes only on republish)
  2. Lay grid from layout and hydrated card_type; do not wait for execution
  3. Render static options immediately; call endpoint 5 for options_sql (send verbatim)
  4. Group cards by dashboard_id; one batch per dashboard (endpoint 3)
  5. Execute code and data-bearing HTML blocks (endpoint 4)
  6. Check error on every result before reading rows
  7. Honor truncated on block path; surface rather than hide
  8. Pass asset_id on card executes
  9. Treat 401/403 as non-retryable stop
  10. Back off on 429 (rate limited)

Pages Using HF Data ​

Page ComponentHF Page KeyDashboardStatus
ExecSummaryPageringkasan-eksekutif + financial + operational244Live
CfoCockpitPagefinancial244Live
OperationalPageoperational244Live
PeriodicTrendsPagetren-periodik244Live
BranchStatisticsPagebranches244Live
BranchComparisonPagebranch-focus244Live
HeatmapPageheatmap-kpi297Live
ServiceLinesPagelini-layanan293Live
PayerPerformancePagekinerja-penjamin295Live
VaccinationPagevaksinasi298Live
LiquidityPagelikuiditas291Live
SPIPagevarians-biaya294Live
PatientRetentionPageretensi-pasien299Live
JKNPagepelayanan-jkn296Live
ClinicalFlowPageclinical244Live
PharmacyPagepharmacy244Live
ReviewPerRSPagereview-rs244Live
WasdalPagewasdal244Live
ConsolidationPagereview-konsolidasi (hermina360_financial)285Live

Pages with no backing cards ​

MCUPage, MaternalPage and AccessManagementPage have no cards in either webapp. They render <DemoDataBanner /> ("Data demo — belum terhubung ke sumber data") over placeholder figures. Wire them only once real cards exist — do not invent an endpoint for them.