@rozaqi02/reusable-dashboard 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,68 +1,283 @@
1
1
  # @rozaqi02/reusable-dashboard
2
2
 
3
- Modul dashboard admin reusable untuk UMKM. Dibangun dengan React + Atomic Design + Supabase.
4
- Mendukung berbagai domain bisnis tanpa mengubah komponen inti.
3
+ Modul dashboard admin reusable untuk aplikasi UMKM berbasis React + Supabase.
4
+ Mendukung berbagai domain bisnis (travel, toko online, UMKM generik) tanpa menulis ulang komponen.
5
+ CSS sudah terbundle — tidak perlu konfigurasi Tailwind.
5
6
 
6
- [![version](https://img.shields.io/badge/version-1.1.0-blue)](./CHANGELOG.md)
7
+ [![version](https://img.shields.io/badge/version-1.1.3-blue)](./CHANGELOG.md)
7
8
  [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
9
+ [![npm](https://img.shields.io/badge/npm-%40rozaqi02%2Freusable--dashboard-red)](https://www.npmjs.com/package/@rozaqi02/reusable-dashboard)
8
10
 
9
11
  ---
10
12
 
11
- ## Install
13
+ ## Daftar Isi
14
+
15
+ 1. [Instalasi](#1-instalasi)
16
+ 2. [Prasyarat Teknis](#2-prasyarat-teknis)
17
+ 3. [Preset yang Tersedia](#3-preset-yang-tersedia)
18
+ 4. [Quick Start — Toko Sepatu (Contoh Lengkap)](#4-quick-start--toko-sepatu)
19
+ 5. [Quick Start — Cidika Travel](#5-quick-start--cidika-travel)
20
+ 6. [Setup Database Supabase](#6-setup-database-supabase)
21
+ 7. [Cara Membuat Adapter untuk Domain Bisnis Baru](#7-cara-membuat-adapter-untuk-domain-bisnis-baru)
22
+ 8. [Konfigurasi Widget](#8-konfigurasi-widget)
23
+ 9. [Label & Internasionalisasi](#9-label--internasionalisasi)
24
+ 10. [API Reference Lengkap](#10-api-reference-lengkap)
25
+ 11. [Pengembangan & Kontribusi](#11-pengembangan--kontribusi)
26
+
27
+ ---
28
+
29
+ ## 1. Instalasi
12
30
 
13
31
  ```bash
14
32
  npm install @rozaqi02/reusable-dashboard
15
33
  npm install recharts @supabase/supabase-js
16
34
  ```
17
35
 
18
- Hanya 2 perintah. CSS sudah otomatis terbundle — tidak perlu konfigurasi Tailwind.
36
+ ### Import CSS
37
+
38
+ CSS modul harus di-import secara eksplisit di file CSS utama project kamu.
39
+
40
+ **Create React App** — tambahkan di `src/index.css`:
41
+ ```css
42
+ @import "../node_modules/@rozaqi02/reusable-dashboard/dist/index.css";
43
+ ```
44
+
45
+ **Vite** — tambahkan di `src/index.css` atau `src/main.jsx`:
46
+ ```css
47
+ /* index.css */
48
+ @import "@rozaqi02/reusable-dashboard/dist/index.css";
49
+ ```
50
+ ```jsx
51
+ // atau di main.jsx
52
+ import "@rozaqi02/reusable-dashboard/dist/index.css";
53
+ ```
54
+
55
+ **Next.js** — tambahkan di `app/globals.css` atau `pages/_app.jsx`:
56
+ ```css
57
+ @import "@rozaqi02/reusable-dashboard/dist/index.css";
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 2. Prasyarat Teknis
63
+
64
+ | Prasyarat | Versi Minimum | Keterangan |
65
+ |-----------|---------------|------------|
66
+ | Node.js | 18.x | Runtime JavaScript (18 atau lebih baru) |
67
+ | React | 18.0.0 | UI framework (mendukung React 19) |
68
+ | recharts | 2.0.0 | Library chart (AreaChart, PieChart, BarChart) |
69
+ | @supabase/supabase-js | 2.0.0 | Client Supabase untuk koneksi database |
70
+ | Akun Supabase | — | Gratis di [supabase.com](https://supabase.com) |
71
+
72
+ Modul ini dirancang khusus untuk aplikasi React yang menggunakan Supabase sebagai backend.
73
+ Belum mendukung backend lain (Firebase, REST API, GraphQL) tanpa menulis data source sendiri.
74
+
75
+ ---
76
+
77
+ ## 3. Preset yang Tersedia
78
+
79
+ Modul menyediakan **3 preset siap pakai** untuk domain bisnis yang berbeda:
80
+
81
+ | Preset | Domain | Tabel Supabase yang Dibutuhkan | Siap Pakai? |
82
+ |--------|--------|-------------------------------|-------------|
83
+ | **Cidika Travel** | Travel agency / paket wisata | `bookings`, `packages`, `package_locales`, `page_sections` | Config + Adapter + Source |
84
+ | **Toko Sepatu** | Toko online / e-commerce | `orders`, `order_items`, `products`, `customers` | Config + Adapter + Source |
85
+ | **Dummy UMKM** | UMKM generik (contoh domain baru) | `orders`, `products`, `customers` | Config + Adapter saja* |
86
+
87
+ > *Preset **Dummy UMKM** menyediakan widget config (`dummyUmkmWidgetConfig`) dan
88
+ > adapter (`adaptDummyUmkmData`), tetapi **tidak** menyertakan data source siap pakai.
89
+ > Data source-nya ditulis manual — lihat contoh di
90
+ > `examples/dummy-umkm-example/DummyUmkmDashboard.jsx` (repo GitHub).
91
+ > Ini sengaja dijadikan **template** untuk kamu yang ingin membuat domain baru
92
+ > (lihat [Bab 7](#7-cara-membuat-adapter-untuk-domain-bisnis-baru)).
93
+
94
+ Hanya **2 data source siap pakai** yang diekspor modul:
95
+ `createCidikaSupabaseSource` dan `createTokoSepatuSupabaseSource`.
96
+
97
+ Setiap preset siap pakai sudah termasuk: konfigurasi widget, data adapter, dan data source Supabase.
19
98
 
20
99
  ---
21
100
 
22
- ## Quick Start (5 menit)
101
+ ## 4. Quick Start Toko Sepatu
102
+
103
+ Ini adalah contoh paling lengkap. Ikuti step by step dari nol.
104
+
105
+ ### Step 1 — Setup Supabase
106
+
107
+ **A. Buat project Supabase** di [app.supabase.com](https://app.supabase.com)
108
+
109
+ **B. Jalankan SQL schema** berikut di Supabase SQL Editor
110
+ (Settings → SQL Editor → New Query):
111
+
112
+ ```sql
113
+ -- Tabel pelanggan
114
+ CREATE TABLE public.customers (
115
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
116
+ name text NOT NULL,
117
+ email text,
118
+ phone text,
119
+ city text,
120
+ created_at timestamp with time zone DEFAULT now(),
121
+ CONSTRAINT customers_pkey PRIMARY KEY (id)
122
+ );
123
+
124
+ -- Tabel produk
125
+ CREATE TABLE public.products (
126
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
127
+ name text NOT NULL,
128
+ brand text NOT NULL DEFAULT 'Generic',
129
+ category text NOT NULL DEFAULT 'sneakers',
130
+ price_idr integer NOT NULL,
131
+ stock integer NOT NULL DEFAULT 0,
132
+ is_active boolean NOT NULL DEFAULT true,
133
+ created_at timestamp with time zone DEFAULT now(),
134
+ CONSTRAINT products_pkey PRIMARY KEY (id)
135
+ );
136
+
137
+ -- Tabel pesanan
138
+ CREATE TABLE public.orders (
139
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
140
+ customer_id uuid NOT NULL,
141
+ total_amount integer NOT NULL,
142
+ status text NOT NULL DEFAULT 'pending',
143
+ created_at timestamp with time zone DEFAULT now(),
144
+ CONSTRAINT orders_pkey PRIMARY KEY (id),
145
+ CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id)
146
+ REFERENCES public.customers(id)
147
+ );
148
+
149
+ -- Tabel detail item pesanan
150
+ CREATE TABLE public.order_items (
151
+ id bigint GENERATED ALWAYS AS IDENTITY,
152
+ order_id uuid NOT NULL,
153
+ product_id uuid NOT NULL,
154
+ qty integer NOT NULL DEFAULT 1,
155
+ price_idr integer NOT NULL,
156
+ subtotal integer NOT NULL,
157
+ CONSTRAINT order_items_pkey PRIMARY KEY (id),
158
+ CONSTRAINT order_items_order_id_fkey FOREIGN KEY (order_id)
159
+ REFERENCES public.orders(id),
160
+ CONSTRAINT order_items_product_id_fkey FOREIGN KEY (product_id)
161
+ REFERENCES public.products(id)
162
+ );
163
+
164
+ -- Aktifkan Realtime (untuk fitur live update)
165
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.orders;
166
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.products;
167
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.customers;
168
+ ```
169
+
170
+ **C. Isi data contoh** (opsional, untuk testing):
171
+
172
+ File seed data lengkap tersedia di repo GitHub:
173
+ `examples/toko-sepatu-example/seed-data.sql`.
174
+
175
+ > Catatan: folder `examples/` **tidak ikut** saat `npm install` (paket npm hanya
176
+ > berisi `dist/`). Untuk mengambil file SQL contoh, kloning repo GitHub atau
177
+ > salin manual dari halaman repositori.
178
+
179
+ **D. Aktifkan Realtime** di Supabase Dashboard:
180
+ Database → Replication → centang tabel `orders`, `products`, `customers`.
23
181
 
24
- ### 1. Buat Supabase client
182
+ ### Step 2 — Buat Supabase client
25
183
 
26
184
  ```js
27
185
  // src/lib/supabaseClient.js
28
186
  import { createClient } from '@supabase/supabase-js';
187
+
29
188
  export const supabase = createClient(
30
- process.env.REACT_APP_SUPABASE_URL,
189
+ process.env.REACT_APP_SUPABASE_URL, // CRA
31
190
  process.env.REACT_APP_SUPABASE_ANON_KEY
191
+ // Jika pakai Vite: import.meta.env.VITE_SUPABASE_URL
32
192
  );
33
193
  ```
34
194
 
35
195
  ```env
36
- # .env
37
- REACT_APP_SUPABASE_URL=https://xxx.supabase.co
38
- REACT_APP_SUPABASE_ANON_KEY=eyJ...
196
+ # .env (di root project)
197
+ REACT_APP_SUPABASE_URL=https://xxxxxx.supabase.co
198
+ REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
199
+
200
+ # Jika pakai Vite:
201
+ # VITE_SUPABASE_URL=https://xxxxxx.supabase.co
202
+ # VITE_SUPABASE_ANON_KEY=eyJ...
39
203
  ```
40
204
 
41
- ### 2. Pakai di halaman dashboard
205
+ Nilai URL dan ANON KEY didapat dari Supabase Dashboard:
206
+ **Settings → API → Project URL & anon key**.
207
+
208
+ ### Step 3 — Implementasi halaman Dashboard
42
209
 
43
210
  ```jsx
211
+ // src/pages/Dashboard.jsx
44
212
  import React, { useMemo } from "react";
45
- import { supabase } from "./lib/supabaseClient";
213
+ import { supabase } from "../lib/supabaseClient";
46
214
  import {
47
215
  ReusableDashboardView,
48
- cidikaWidgetConfig,
49
- createCidikaSupabaseSource,
50
- adaptCidikaDashboardData,
51
- createEmptyDashboardData,
52
- createDashboardLabels,
216
+ tokoSepatuWidgetConfig,
217
+ createTokoSepatuSupabaseSource,
218
+ adaptTokoSepatuData,
219
+ createEmptyTokoSepatuData,
53
220
  useReusableDashboard,
54
221
  } from "@rozaqi02/reusable-dashboard";
55
222
 
56
- const source = createCidikaSupabaseSource(supabase);
223
+ // Inisialisasi data source dibuat sekali di luar komponen
224
+ const source = createTokoSepatuSupabaseSource(supabase);
57
225
 
58
- export default function Dashboard() {
59
- const labels = useMemo(() => createDashboardLabels((key) => key), []);
226
+ // Label UI dalam Bahasa Indonesia
227
+ // Ganti nilai-nilai ini sesuai bahasa/konteks bisnis kamu
228
+ const labels = {
229
+ title: "Dashboard Toko Saya",
230
+ refresh: "Refresh",
231
+ liveUpdate: "Live",
232
+ loadFailed: "Gagal memuat data.",
233
+ retry: "Coba Lagi",
234
+ confirmedOnly: "Confirmed",
235
+ pendingOnly: "Pending",
236
+ allStatus: "Semua Status",
237
+ showPendingOverlay: "Tampilkan pending",
238
+ allAudience: "Semua",
239
+ audienceDomestic: "Domestic",
240
+ audienceForeign: "Foreign",
241
+ customDate: "Kustom",
242
+ reset: "Reset",
243
+ topSort: "Urutkan",
244
+ sortBookings: "Qty Terjual",
245
+ sortRevenue: "Revenue",
246
+ sortDesc: "Turun",
247
+ sortAsc: "Naik",
248
+ confirmedBookings: "Total Pesanan",
249
+ confirmedRevenue: "Total Pendapatan",
250
+ avgRevenue: "Rata-rata / Pesanan",
251
+ conversionRate: "Conversion Rate",
252
+ totalProducts: "Total Produk",
253
+ dailyTrends: "Tren Harian",
254
+ statusDistribution: "Distribusi Status",
255
+ topPackages: "Produk Terlaris",
256
+ recentBookings: "Pesanan Terbaru",
257
+ date: "Tanggal",
258
+ customer: "Pelanggan",
259
+ package: "Produk",
260
+ audience: "Audiens",
261
+ total: "Total",
262
+ status: "Status",
263
+ noRecentBookings: "Belum ada pesanan",
264
+ bookingsMetric: "Pesanan",
265
+ revenueMetric: "Pendapatan",
266
+ pendingMetric: "Pesanan (Pending)",
267
+ confirmedBookingMetric: "Pesanan (Confirmed)",
268
+ confirmedRevenueMetric: "Pendapatan (Confirmed)",
269
+ unknownAudience: "Unknown",
270
+ dayLabel: (n) => `${n} hari`,
271
+ formatStatusLabel: (s) => ({ confirmed: "Confirmed", pending: "Pending", cancelled: "Cancelled" })[s] || s,
272
+ formatAudienceLabel: (v) => v || "Unknown",
273
+ };
60
274
 
275
+ export default function Dashboard() {
61
276
  const state = useReusableDashboard({
62
- config: cidikaWidgetConfig,
277
+ config: tokoSepatuWidgetConfig,
63
278
  dataSource: source,
64
- adapter: adaptCidikaDashboardData,
65
- createEmptyState: createEmptyDashboardData,
279
+ adapter: adaptTokoSepatuData,
280
+ createEmptyState: createEmptyTokoSepatuData,
66
281
  languageCode: "id",
67
282
  dateLocale: "id-ID",
68
283
  labels,
@@ -70,7 +285,7 @@ export default function Dashboard() {
70
285
 
71
286
  return (
72
287
  <ReusableDashboardView
73
- config={cidikaWidgetConfig}
288
+ config={tokoSepatuWidgetConfig}
74
289
  labels={labels}
75
290
  loading={state.loading}
76
291
  error={state.error}
@@ -86,77 +301,905 @@ export default function Dashboard() {
86
301
  }
87
302
  ```
88
303
 
89
- ### 3. Jalankan
304
+ ### Step 4 — Jalankan
90
305
 
91
306
  ```bash
92
- npm start
307
+ npm start # Create React App
308
+ # atau
309
+ npm run dev # Vite
310
+ ```
311
+
312
+ Buka browser → dashboard tampil dengan data dari Supabase.
313
+
314
+ ---
315
+
316
+ ## 5. Quick Start — Cidika Travel
317
+
318
+ Ganti hanya 4 baris dari contoh Toko Sepatu di atas:
319
+
320
+ ```jsx
321
+ import {
322
+ ReusableDashboardView,
323
+ cidikaWidgetConfig, // ← ganti ini
324
+ createCidikaSupabaseSource, // ← ganti ini
325
+ adaptCidikaDashboardData, // ← ganti ini
326
+ createEmptyDashboardData, // ← ganti ini
327
+ createDashboardLabels,
328
+ useReusableDashboard,
329
+ } from "@rozaqi02/reusable-dashboard";
330
+
331
+ // Jika pakai react-i18next:
332
+ const { t } = useTranslation();
333
+ const labels = useMemo(() => createDashboardLabels(t), [t]);
334
+
335
+ // Jika tidak pakai i18n, gunakan objek labels manual seperti contoh Toko Sepatu
336
+
337
+ const source = createCidikaSupabaseSource(supabase);
338
+
339
+ const state = useReusableDashboard({
340
+ config: cidikaWidgetConfig,
341
+ dataSource: source,
342
+ adapter: adaptCidikaDashboardData,
343
+ createEmptyState: createEmptyDashboardData,
344
+ languageCode: "id",
345
+ dateLocale: "id-ID",
346
+ labels,
347
+ });
348
+ ```
349
+
350
+ **Tabel Supabase yang dibutuhkan untuk Cidika Travel:**
351
+
352
+ ```sql
353
+ -- Tabel utama
354
+ bookings (id, created_at, total_idr, status, package_id, audience, customer_name)
355
+ packages (id, slug, price, is_active)
356
+ package_locales (package_id, lang, title) -- untuk nama paket multi-bahasa
357
+ page_sections (id, page_id, type, order) -- untuk menghitung jumlah section
358
+ ```
359
+
360
+ ---
361
+
362
+ ## 6. Setup Database Supabase
363
+
364
+ ### Cara mendapatkan URL dan API Key
365
+
366
+ 1. Login ke [app.supabase.com](https://app.supabase.com)
367
+ 2. Buka project kamu
368
+ 3. Klik **Settings** (ikon gear) di sidebar kiri
369
+ 4. Klik **API**
370
+ 5. Salin:
371
+ - **Project URL** → `REACT_APP_SUPABASE_URL`
372
+ - **anon public** key → `REACT_APP_SUPABASE_ANON_KEY`
373
+
374
+ ### Mengaktifkan Realtime (wajib untuk live update)
375
+
376
+ 1. Di Supabase Dashboard, klik **Database** di sidebar
377
+ 2. Klik **Replication**
378
+ 3. Di bagian **Source**, aktifkan toggle untuk tabel yang ingin di-listen
379
+ 4. Atau jalankan SQL berikut:
380
+
381
+ ```sql
382
+ -- Untuk Toko Sepatu:
383
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.orders;
384
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.products;
385
+
386
+ -- Untuk Cidika Travel:
387
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.bookings;
388
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.packages;
389
+ ```
390
+
391
+ ### Row Level Security (RLS)
392
+
393
+ Jika dashboard tidak menampilkan data padahal data sudah ada, kemungkinan
394
+ RLS (Row Level Security) memblokir query. Solusi sementara untuk development:
395
+
396
+ ```sql
397
+ -- PERHATIAN: Hanya untuk development. Jangan gunakan di production
398
+ -- tanpa menambahkan policy yang benar.
399
+ ALTER TABLE public.orders DISABLE ROW LEVEL SECURITY;
400
+ ALTER TABLE public.customers DISABLE ROW LEVEL SECURITY;
401
+ ALTER TABLE public.products DISABLE ROW LEVEL SECURITY;
402
+ ```
403
+
404
+ Untuk production, tambahkan policy yang sesuai:
405
+
406
+ ```sql
407
+ -- Contoh policy: izinkan semua operasi SELECT untuk role anon
408
+ CREATE POLICY "Allow read for anon" ON public.orders
409
+ FOR SELECT TO anon USING (true);
410
+ ```
411
+
412
+ ---
413
+
414
+ ## 7. Cara Membuat Adapter untuk Domain Bisnis Baru
415
+
416
+ Bab ini adalah panduan **dari nol** untuk client yang domainnya **bukan** travel
417
+ atau toko sepatu (misal: laundry, klinik, rental mobil, kursus, dll). Kamu hanya
418
+ perlu menulis **3 file kecil**, tanpa pernah menyentuh kode inti modul.
419
+
420
+ ### 7.0 Pahami dulu alurnya (WAJIB dibaca)
421
+
422
+ Modul ini memisahkan 3 tanggung jawab. Memahami peran masing-masing membuat
423
+ sisanya gampang:
424
+
425
+ ```
426
+ DATABASE Supabase
427
+
428
+
429
+ ┌───────────────┐ "Ambil data mentah dari Supabase"
430
+ │ 1. DATA SOURCE │ → tahu nama tabel & kolom kamu
431
+ └───────────────┘ → output: objek { bookings, recent, staticCounts }
432
+
433
+
434
+ ┌───────────────┐ "Ubah data mentah → format standar dashboard"
435
+ │ 2. ADAPTER │ → hitung stats, susun data chart & tabel
436
+ └───────────────┘ → output: objek { stats, charts, table }
437
+
438
+
439
+ ┌───────────────┐ "Tentukan WIDGET apa yang tampil & labelnya"
440
+ │ 3. WIDGET │ → kartu mana, chart mana, kolom tabel mana
441
+ │ CONFIG │
442
+ └───────────────┘
443
+
444
+
445
+ useReusableDashboard(...) → <ReusableDashboardView /> (UI jadi)
446
+ ```
447
+
448
+ Aturan emas:
449
+ - **Data Source** = satu-satunya bagian yang tahu nama tabel/kolom database kamu.
450
+ - **Adapter** = satu-satunya bagian yang berisi logika hitung (revenue, konversi, dll).
451
+ - **Widget Config** = satu-satunya bagian yang menentukan tampilan (deklaratif, tanpa logika).
452
+
453
+ Urutan mengerjakan: **Data Source → Adapter → Widget Config → rangkai di halaman**.
454
+
455
+ ---
456
+
457
+ ### 7.1 Kontrak antar-lapisan (referensi cepat)
458
+
459
+ **Output Data Source** (yang masuk ke adapter sebagai `raw`):
460
+
461
+ ```javascript
462
+ {
463
+ bookings: [...], // WAJIB. Array semua transaksi dalam rentang tanggal.
464
+ recent: [...], // WAJIB. Array 10 transaksi terbaru (untuk tabel).
465
+ packageLocales: [], // Opsional. Untuk nama multi-bahasa. Boleh [].
466
+ staticCounts: { // Opsional. Angka agregat (mis. jumlah produk).
467
+ packages: 0,
468
+ sections: 0,
469
+ },
470
+ }
471
+ ```
472
+
473
+ > Nama field **harus** `bookings` dan `recent` walau domainmu bukan booking —
474
+ > ini istilah internal modul. Isi datanya bebas (order, transaksi, reservasi, dll).
475
+
476
+ **Output Adapter** (yang dirender oleh komponen):
477
+
478
+ ```javascript
479
+ {
480
+ stats: {
481
+ bookingsConfirm: number, // angka untuk stat card (key bebas, asal cocok config)
482
+ revenueConfirm: number,
483
+ // ...tambahkan key lain sesuai kebutuhan
484
+ },
485
+ charts: {
486
+ dailyTrends: [ // area chart tren harian
487
+ { dateKey: "2026-05-01", label: "01 Mei", count: 5, revenue: 2500000, pendingCount: 2 }
488
+ ],
489
+ statusDistribution: [ // pie chart distribusi status
490
+ { status: "confirmed", label: "Confirmed", count: 30 },
491
+ ],
492
+ audienceDistribution: [], // pie chart kedua (boleh kosong [])
493
+ topPackages: [ // bar chart terlaris (boleh kosong [])
494
+ { packageId: "uuid", name: "Nama Produk", value: 10 },
495
+ ],
496
+ },
497
+ table: {
498
+ recentBookings: [ // baris tabel transaksi terbaru
499
+ {
500
+ id: "uuid",
501
+ createdAt: "2026-05-01T10:00:00Z", // ISO string
502
+ customerName: "Budi Santoso",
503
+ packageName: "Nama Produk",
504
+ audienceLabel: "Domestic", // boleh "-"
505
+ totalIDR: 500000, // integer Rupiah
506
+ status: "confirmed", // lowercase
507
+ statusLabel: "Confirmed",
508
+ }
509
+ ],
510
+ },
511
+ }
512
+ ```
513
+
514
+ > Penting: `valueKey` di widget config (Bab 8) harus cocok dengan key di `stats`.
515
+ > Contoh: jika config punya `valueKey: "revenueConfirm"`, adapter wajib
516
+ > mengembalikan `stats.revenueConfirm`.
517
+
518
+ ---
519
+
520
+ ### 7.2 STUDI KASUS: Aplikasi Laundry (dari nol)
521
+
522
+ Misalkan client punya 1 tabel Supabase bernama `laundry_orders`:
523
+
524
+ | Kolom | Tipe | Contoh |
525
+ |-------|------|--------|
526
+ | `id` | uuid | `a1b2...` |
527
+ | `created_at` | timestamptz | `2026-05-01T10:00:00Z` |
528
+ | `customer_name` | text | `Budi Santoso` |
529
+ | `total_price` | integer | `50000` |
530
+ | `status` | text | `confirmed` / `pending` |
531
+ | `service_type` | text | `Cuci Kering` |
532
+
533
+ Target dashboard: 2 stat card (total order, total pendapatan), 2 chart
534
+ (tren harian + distribusi status), dan 1 tabel order terbaru.
535
+
536
+ ---
537
+
538
+ #### Langkah 1 — Tulis DATA SOURCE
539
+
540
+ Buat file `src/datasources/laundryDataSource.js`. Tugasnya: query Supabase,
541
+ kembalikan dalam bentuk `{ bookings, recent, staticCounts }`.
542
+
543
+ ```javascript
544
+ // src/datasources/laundryDataSource.js
545
+ export function createLaundrySupabaseSource(supabase) {
546
+ return {
547
+ // Dipanggil otomatis oleh useReusableDashboard setiap filter berubah.
548
+ // Parameter fromISO/toISO/statusScope sudah dihitung oleh modul.
549
+ async fetchDashboardSnapshot({ fromISO, toISO, statusScope }) {
550
+ // (a) Semua order dalam rentang tanggal → untuk chart & stats
551
+ const { data: orders = [] } = await supabase
552
+ .from("laundry_orders")
553
+ .select("id, created_at, customer_name, total_price, status, service_type")
554
+ .gte("created_at", fromISO)
555
+ .lte("created_at", toISO)
556
+ .order("created_at", { ascending: true });
557
+
558
+ // (b) 10 order terbaru → untuk tabel "Order Terbaru"
559
+ const recentQuery = supabase
560
+ .from("laundry_orders")
561
+ .select("id, created_at, customer_name, total_price, status, service_type")
562
+ .gte("created_at", fromISO)
563
+ .lte("created_at", toISO)
564
+ .order("created_at", { ascending: false })
565
+ .limit(10);
566
+
567
+ // Hormati filter status dari UI (confirmed/pending/all)
568
+ if (statusScope && statusScope !== "all") {
569
+ recentQuery.eq("status", statusScope);
570
+ }
571
+ const { data: recent = [] } = await recentQuery;
572
+
573
+ // (c) Angka agregat opsional, mis. jumlah jenis layanan
574
+ const { count: serviceCount } = await supabase
575
+ .from("services")
576
+ .select("*", { count: "exact", head: true });
577
+
578
+ // Kembalikan SESUAI KONTRAK (nama bookings & recent wajib)
579
+ return {
580
+ bookings: orders,
581
+ recent,
582
+ packageLocales: [],
583
+ staticCounts: { packages: serviceCount || 0, sections: 0 },
584
+ };
585
+ },
586
+
587
+ // OPSIONAL — aktifkan live update realtime. Hapus jika tak perlu.
588
+ subscribeLiveUpdate(onEvent) {
589
+ const channel = supabase
590
+ .channel("laundry-dashboard-live")
591
+ .on("postgres_changes",
592
+ { event: "*", schema: "public", table: "laundry_orders" }, onEvent)
593
+ .subscribe();
594
+ return () => supabase.removeChannel(channel);
595
+ },
596
+ };
597
+ }
598
+ ```
599
+
600
+ > Tips: kalau kamu **tidak** butuh realtime, cukup hapus fungsi
601
+ > `subscribeLiveUpdate`. Badge "Live" otomatis tidak muncul.
602
+
603
+ ---
604
+
605
+ #### Langkah 2 — Tulis ADAPTER
606
+
607
+ Buat file `src/adapters/laundryAdapter.js`. Tugasnya: ubah `raw` dari data source
608
+ menjadi `{ stats, charts, table }`. Pakai helper `toNumber` & `buildDayBuckets`
609
+ dari modul agar tidak menulis ulang logika tanggal.
610
+
611
+ ```javascript
612
+ // src/adapters/laundryAdapter.js
613
+ import { toNumber, buildDayBuckets } from "@rozaqi02/reusable-dashboard";
614
+
615
+ // (1) State kosong — dipakai modul sebagai initial state & saat error.
616
+ // Strukturnya HARUS sama dengan output adaptLaundryData di bawah.
617
+ export function createEmptyLaundryData() {
618
+ return {
619
+ stats: { bookingsConfirm: 0, revenueConfirm: 0 },
620
+ charts: { dailyTrends: [], statusDistribution: [], audienceDistribution: [], topPackages: [] },
621
+ table: { recentBookings: [] },
622
+ };
623
+ }
624
+
625
+ // (2) Fungsi adapter utama.
626
+ export function adaptLaundryData({ raw, filters, range, dateLocale, labels }) {
627
+ if (!raw) return createEmptyLaundryData();
628
+
629
+ const orders = raw.bookings || [];
630
+ const recent = raw.recent || [];
631
+
632
+ // Siapkan bucket harian kosong untuk chart tren (1 bucket = 1 hari)
633
+ const dailyBuckets = buildDayBuckets(range.daysWindow, range.fromISO, dateLocale);
634
+ const dayLookup = new Map(dailyBuckets.map((b) => [b.dateKey, b]));
635
+
636
+ const statusMap = new Map();
637
+ let confirmed = 0;
638
+ let revenue = 0;
639
+
640
+ // Loop semua order: hitung total & isi bucket harian
641
+ orders.forEach((row) => {
642
+ const status = String(row.status || "pending").toLowerCase();
643
+ const amount = toNumber(row.total_price); // ← kolom DB kamu
644
+ const dayKey = String(row.created_at || "").slice(0, 10);
645
+
646
+ statusMap.set(status, (statusMap.get(status) || 0) + 1);
647
+
648
+ const bucket = dayLookup.get(dayKey);
649
+ if (bucket && status === "confirmed") {
650
+ bucket.count += 1;
651
+ bucket.revenue += amount;
652
+ }
653
+
654
+ if (status === "confirmed") { confirmed += 1; revenue += amount; }
655
+ });
656
+
657
+ return {
658
+ // stats: key di sini HARUS cocok dengan valueKey di widget config
659
+ stats: {
660
+ bookingsConfirm: confirmed,
661
+ revenueConfirm: revenue,
662
+ },
663
+ charts: {
664
+ dailyTrends: dailyBuckets,
665
+ statusDistribution: Array.from(statusMap.entries()).map(([status, count]) => ({
666
+ status,
667
+ label: labels?.formatStatusLabel?.(status) || status,
668
+ count,
669
+ })),
670
+ audienceDistribution: [], // tidak dipakai laundry → kosong
671
+ topPackages: [], // tidak dipakai laundry → kosong
672
+ },
673
+ table: {
674
+ recentBookings: recent.map((row) => {
675
+ const status = String(row.status || "pending").toLowerCase();
676
+ return {
677
+ id: row.id,
678
+ createdAt: row.created_at,
679
+ customerName: row.customer_name || "-",
680
+ packageName: row.service_type || "-", // ← kolom DB kamu
681
+ audienceLabel: "-",
682
+ totalIDR: toNumber(row.total_price),
683
+ status,
684
+ statusLabel: labels?.formatStatusLabel?.(status) || status,
685
+ };
686
+ }),
687
+ },
688
+ };
689
+ }
93
690
  ```
94
691
 
95
- Selesai.
692
+ > Catatan: `audienceDistribution` dan `topPackages` boleh `[]` jika domainmu
693
+ > tidak butuh. Lebih baik lagi: **jangan daftarkan** chart itu di widget config
694
+ > (Langkah 3), supaya tidak tampil sama sekali.
96
695
 
97
696
  ---
98
697
 
99
- ## Preset yang tersedia
698
+ #### Langkah 3 — Tulis WIDGET CONFIG
699
+
700
+ Buat file `src/config/laundryConfig.js`. Ini murni deklaratif — menentukan
701
+ widget apa yang muncul. `valueKey` harus cocok dengan key `stats` dari adapter,
702
+ dan `label` adalah key dari objek labels (Langkah 4).
703
+
704
+ ```javascript
705
+ // src/config/laundryConfig.js
706
+ export const laundryConfig = {
707
+ id: "laundry.dashboard",
708
+ defaultFilters: {
709
+ statusScope: "confirmed", // filter awal saat dibuka
710
+ daysPreset: 30, // tampilkan 30 hari terakhir
711
+ sortPkgBy: "bookings",
712
+ sortPkgDir: "desc",
713
+ },
714
+ widgets: {
715
+ // 2 stat card → valueKey cocok dgn stats di adapter
716
+ stats: [
717
+ { id: "orders", label: "confirmedBookings", icon: "TrendingUp",
718
+ valueKey: "bookingsConfirm", format: "number", accentColor: "blue" },
719
+ { id: "revenue", label: "confirmedRevenue", icon: "DollarSign",
720
+ valueKey: "revenueConfirm", format: "currency", accentColor: "green" },
721
+ ],
722
+ // 2 chart → hanya yang ada datanya (tren & status)
723
+ charts: [
724
+ { id: "trend", type: "dailyArea", label: "dailyTrends", icon: "BarChart3" },
725
+ { id: "status", type: "statusPie", label: "statusDistribution", icon: "PieChart" },
726
+ ],
727
+ // 1 tabel → kolom mengacu ke field recentBookings dari adapter
728
+ table: {
729
+ id: "recentOrders",
730
+ label: "recentBookings",
731
+ icon: "Calendar",
732
+ emptyLabel: "noRecentBookings",
733
+ columns: [
734
+ { id: "date", label: "date", accessor: "createdAt", type: "date" },
735
+ { id: "customer", label: "customer", accessor: "customerName" },
736
+ { id: "service", label: "package", accessor: "packageName" },
737
+ { id: "total", label: "total", accessor: "totalIDR", type: "currency" },
738
+ { id: "status", label: "status", accessor: "statusLabel",
739
+ type: "statusBadge", statusAccessor: "status" },
740
+ ],
741
+ },
742
+ },
743
+ };
744
+ ```
100
745
 
101
- | Preset | Import | Tabel Supabase yang dibutuhkan |
102
- |--------|--------|-------------------------------|
103
- | **Cidika Travel** | `cidikaWidgetConfig`, `createCidikaSupabaseSource`, `adaptCidikaDashboardData` | `bookings`, `packages`, `package_locales`, `page_sections` |
104
- | **Toko Sepatu** | `tokoSepatuWidgetConfig`, `createTokoSepatuSupabaseSource`, `adaptTokoSepatuData` | `orders`, `order_items`, `products`, `customers` |
105
- | **Dummy UMKM** | `dummyUmkmWidgetConfig`, `adaptDummyUmkmData` | (gunakan salah satu source di atas) |
746
+ > Detail tiap field config (tipe kolom, nama ikon, format) ada di
747
+ > [Bab 8 — Konfigurasi Widget](#8-konfigurasi-widget).
106
748
 
107
749
  ---
108
750
 
109
- ## Pakai dengan i18n (react-i18next)
751
+ #### Langkah 4 Rangkai semuanya di halaman Dashboard
752
+
753
+ Sekarang gabungkan ke-3 file di atas + objek labels, lalu serahkan ke
754
+ `useReusableDashboard` dan render `ReusableDashboardView`.
755
+
756
+ ```jsx
757
+ // src/pages/LaundryDashboard.jsx
758
+ import React, { useMemo } from "react";
759
+ import { supabase } from "../lib/supabaseClient";
760
+ import {
761
+ ReusableDashboardView,
762
+ useReusableDashboard,
763
+ } from "@rozaqi02/reusable-dashboard";
764
+
765
+ // 3 file yang baru kamu buat:
766
+ import { laundryConfig } from "../config/laundryConfig";
767
+ import { createLaundrySupabaseSource } from "../datasources/laundryDataSource";
768
+ import { adaptLaundryData, createEmptyLaundryData } from "../adapters/laundryAdapter";
769
+
770
+ // Data source dibuat sekali di luar komponen (anti re-render)
771
+ const source = createLaundrySupabaseSource(supabase);
772
+
773
+ // Label UI — semua teks yang tampil. Sesuaikan bahasa/konteks bisnismu.
774
+ const labels = {
775
+ title: "Dashboard Laundry",
776
+ refresh: "Refresh",
777
+ liveUpdate: "Live",
778
+ loadFailed: "Gagal memuat data.",
779
+ retry: "Coba Lagi",
780
+ allStatus: "Semua Status",
781
+ confirmedOnly: "Selesai",
782
+ pendingOnly: "Proses",
783
+ reset: "Reset",
784
+ confirmedBookings: "Total Order",
785
+ confirmedRevenue: "Total Pendapatan",
786
+ dailyTrends: "Tren Harian",
787
+ statusDistribution: "Distribusi Status",
788
+ recentBookings: "Order Terbaru",
789
+ noRecentBookings: "Belum ada order",
790
+ date: "Tanggal",
791
+ customer: "Pelanggan",
792
+ package: "Layanan",
793
+ total: "Total",
794
+ status: "Status",
795
+ bookingsMetric: "Order",
796
+ revenueMetric: "Pendapatan",
797
+ confirmedBookingMetric: "Order (Selesai)",
798
+ confirmedRevenueMetric: "Pendapatan (Selesai)",
799
+ dayLabel: (n) => `${n} hari`,
800
+ formatStatusLabel: (s) =>
801
+ ({ confirmed: "Selesai", pending: "Proses", cancelled: "Batal" })[s] || s,
802
+ formatAudienceLabel: (v) => v || "-",
803
+ };
804
+
805
+ export default function LaundryDashboard() {
806
+ const state = useReusableDashboard({
807
+ config: laundryConfig, // ← Langkah 3
808
+ dataSource: source, // ← Langkah 1
809
+ adapter: adaptLaundryData, // ← Langkah 2
810
+ createEmptyState: createEmptyLaundryData, // ← Langkah 2
811
+ languageCode: "id",
812
+ dateLocale: "id-ID",
813
+ labels,
814
+ });
815
+
816
+ return (
817
+ <ReusableDashboardView
818
+ config={laundryConfig}
819
+ labels={labels}
820
+ loading={state.loading}
821
+ error={state.error}
822
+ filters={state.filters}
823
+ onFilterChange={state.updateFilter}
824
+ onResetFilters={state.resetFilters}
825
+ onRefresh={state.refresh}
826
+ data={state.data}
827
+ dateLocale="id-ID"
828
+ liveUpdateEnabled={state.liveUpdateEnabled}
829
+ />
830
+ );
831
+ }
832
+ ```
833
+
834
+ Selesai. Buka halaman tersebut → dashboard laundry tampil lengkap dengan data
835
+ dari Supabase, filter tanggal, search, sort, dan pagination — semua sudah
836
+ ditangani modul.
837
+
838
+ ---
839
+
840
+ ### 7.3 Checklist & troubleshooting domain baru
841
+
842
+ | Gejala | Penyebab umum | Solusi |
843
+ |--------|---------------|--------|
844
+ | Stat card menampilkan `0` terus | `valueKey` di config tidak cocok dengan key `stats` adapter | Samakan nama, mis. `valueKey: "revenueConfirm"` ↔ `stats.revenueConfirm` |
845
+ | Chart kosong | Data source tidak mengembalikan `bookings`, atau status bukan `"confirmed"` | Pastikan field bernama `bookings` & status lowercase |
846
+ | Tabel kosong padahal ada data | Data source tidak mengembalikan `recent` | Tambahkan query `recent` (10 terbaru) |
847
+ | Error "is not a function" | Lupa pasang `createEmptyState` | Wajib kirim `createEmptyXxxData` ke hook |
848
+ | Label tampil sebagai key mentah | Key di config (`label`) tidak ada di objek `labels` | Tambahkan key tersebut di objek labels |
849
+ | Badge "Live" tidak muncul | `subscribeLiveUpdate` tidak ada / Realtime belum aktif | Tambahkan fungsi + aktifkan Realtime di Supabase |
850
+
851
+ Checklist final sebelum rilis ke client:
852
+ - [ ] Data source mengembalikan `{ bookings, recent }` (nama persis).
853
+ - [ ] Adapter mengembalikan `{ stats, charts, table }` lengkap.
854
+ - [ ] `createEmptyXxxData` punya struktur sama dengan output adapter.
855
+ - [ ] Semua `valueKey` di config ada di `stats`.
856
+ - [ ] Semua `label`/`emptyLabel` di config ada di objek `labels`.
857
+ - [ ] RLS Supabase mengizinkan `SELECT` untuk role `anon` (lihat [Bab 6](#6-setup-database-supabase)).
858
+
859
+ ---
860
+
861
+ ## 8. Konfigurasi Widget
862
+
863
+ Widget configuration menentukan apa yang ditampilkan di dashboard.
864
+ Kamu bisa gunakan preset yang ada atau buat konfigurasi baru.
865
+
866
+ ```javascript
867
+ export const myConfig = {
868
+ id: "my.dashboard",
869
+
870
+ // Filter default saat pertama kali dibuka
871
+ defaultFilters: {
872
+ statusScope: "confirmed", // "confirmed" | "pending" | "all"
873
+ includePendingOverlay: false, // tampilkan overlay pending di chart?
874
+ audience: "", // "" | "domestic" | "foreign"
875
+ daysPreset: 30, // 7 | 30 | 90 | 0 (0 = custom range)
876
+ sortPkgBy: "revenue", // "bookings" | "revenue"
877
+ sortPkgDir: "desc", // "desc" | "asc"
878
+ },
879
+
880
+ widgets: {
881
+ // Stat cards (1–4 kartu)
882
+ stats: [
883
+ {
884
+ id: "totalOrders",
885
+ label: "confirmedBookings", // key dari objek labels
886
+ icon: "TrendingUp", // nama ikon Lucide
887
+ valueKey: "bookingsConfirm", // key dari data.stats yang dikembalikan adapter
888
+ format: "number", // "number" | "currency" | "percent"
889
+ accentColor: "blue", // "blue" | "green" | "violet" | "orange" | "sky" | "rose"
890
+ },
891
+ {
892
+ id: "totalRevenue",
893
+ label: "confirmedRevenue",
894
+ icon: "DollarSign",
895
+ valueKey: "revenueConfirm",
896
+ format: "currency",
897
+ accentColor: "green",
898
+ },
899
+ ],
900
+
901
+ // Chart cards (1–4 chart)
902
+ charts: [
903
+ {
904
+ id: "trendHarian",
905
+ type: "dailyArea", // "dailyArea" | "statusPie" | "audiencePie" | "topPackagesBar"
906
+ label: "dailyTrends", // key dari objek labels
907
+ icon: "BarChart3",
908
+ },
909
+ {
910
+ id: "distribusiStatus",
911
+ type: "statusPie",
912
+ label: "statusDistribution",
913
+ icon: "PieChart",
914
+ },
915
+ ],
916
+
917
+ // Tabel (hanya 1)
918
+ table: {
919
+ id: "recentOrders",
920
+ label: "recentBookings", // key dari objek labels
921
+ icon: "Calendar",
922
+ emptyLabel: "noRecentBookings", // key dari objek labels, untuk empty state
923
+ columns: [
924
+ { id: "date", label: "date", accessor: "createdAt", type: "date" },
925
+ { id: "customer", label: "customer", accessor: "customerName" },
926
+ { id: "product", label: "package", accessor: "packageName" },
927
+ { id: "total", label: "total", accessor: "totalIDR", type: "currency" },
928
+ {
929
+ id: "status", label: "status", accessor: "statusLabel",
930
+ type: "statusBadge",
931
+ statusAccessor: "status", // kolom yang berisi status lowercase (confirmed/pending/cancelled)
932
+ },
933
+ ],
934
+ },
935
+ },
936
+ };
937
+ ```
938
+
939
+ **Tipe kolom tabel:**
940
+ | `type` | Format Output |
941
+ |--------|---------------|
942
+ | `date` | Diformat dengan `dateLocale` (contoh: "01 Mei 2026") |
943
+ | `currency` | Diformat dengan prefix "Rp" dan titik ribuan |
944
+ | `statusBadge` | Badge berwarna sesuai status |
945
+ | _(tanpa type)_ | Ditampilkan apa adanya (string) |
946
+
947
+ **Nama ikon yang tersedia:**
948
+ `TrendingUp`, `TrendingDown`, `DollarSign`, `Users`, `PieChart`, `BarChart3`,
949
+ `Calendar`, `RotateCcw`, `Search`, `ChevronLeft`, `ChevronRight`, `ArrowUp`,
950
+ `ArrowDown`, `AlertCircle`
951
+
952
+ ---
953
+
954
+ ## 9. Label & Internasionalisasi
955
+
956
+ ### Tanpa i18n (objek manual)
957
+
958
+ Buat objek labels dengan semua key yang diperlukan:
959
+
960
+ ```javascript
961
+ const labels = {
962
+ // Header
963
+ title: "Dashboard",
964
+ refresh: "Refresh",
965
+ liveUpdate: "Live",
966
+ loadFailed: "Gagal memuat data.",
967
+ retry: "Coba Lagi",
968
+
969
+ // Filter
970
+ confirmedOnly: "Confirmed",
971
+ pendingOnly: "Pending",
972
+ allStatus: "Semua Status",
973
+ showPendingOverlay: "Tampilkan pending",
974
+ allAudience: "Semua",
975
+ audienceDomestic: "Domestic",
976
+ audienceForeign: "Foreign",
977
+ customDate: "Kustom",
978
+ reset: "Reset",
979
+ topSort: "Urutkan",
980
+ sortBookings: "Qty Terjual",
981
+ sortRevenue: "Revenue",
982
+ sortDesc: "Turun",
983
+ sortAsc: "Naik",
984
+
985
+ // Stat cards (sesuaikan dengan valueKey di config)
986
+ confirmedBookings: "Total Pesanan",
987
+ confirmedRevenue: "Total Pendapatan",
988
+ avgRevenue: "Rata-rata / Pesanan",
989
+ conversionRate: "Conversion Rate",
990
+ totalProducts: "Total Produk",
991
+
992
+ // Chart & tabel
993
+ dailyTrends: "Tren Harian",
994
+ statusDistribution: "Distribusi Status",
995
+ audienceDistribution: "Distribusi Audiens",
996
+ topPackages: "Produk Terlaris",
997
+ recentBookings: "Pesanan Terbaru",
998
+ date: "Tanggal",
999
+ customer: "Pelanggan",
1000
+ package: "Produk",
1001
+ audience: "Audiens",
1002
+ total: "Total",
1003
+ status: "Status",
1004
+ noRecentBookings: "Belum ada pesanan",
1005
+
1006
+ // Chart metrics
1007
+ bookingsMetric: "Pesanan",
1008
+ revenueMetric: "Pendapatan",
1009
+ pendingMetric: "Pesanan (Pending)",
1010
+ confirmedBookingMetric: "Pesanan (Confirmed)",
1011
+ confirmedRevenueMetric: "Pendapatan (Confirmed)",
1012
+ unknownAudience: "Unknown",
1013
+
1014
+ // Fungsi formatter (wajib ada)
1015
+ dayLabel: (n) => `${n} hari`,
1016
+ formatStatusLabel: (status) => ({
1017
+ confirmed: "Confirmed",
1018
+ pending: "Pending",
1019
+ cancelled: "Cancelled",
1020
+ shipped: "Dikirim",
1021
+ delivered: "Terkirim",
1022
+ })[status] || status,
1023
+ formatAudienceLabel: (value) => value || "Unknown",
1024
+ };
1025
+ ```
1026
+
1027
+ ### Dengan react-i18next
110
1028
 
111
1029
  ```jsx
112
1030
  import { useTranslation } from "react-i18next";
113
1031
  import { createDashboardLabels } from "@rozaqi02/reusable-dashboard";
114
1032
 
115
- const { t } = useTranslation();
116
- const labels = useMemo(() => createDashboardLabels(t), [t]);
1033
+ function Dashboard() {
1034
+ const { t, i18n } = useTranslation();
1035
+ const languageCode = (i18n.language || "id").slice(0, 2);
1036
+ const dateLocale = languageCode === "id" ? "id-ID" : "en-US";
1037
+ const labels = useMemo(() => createDashboardLabels(t), [t]);
1038
+ // ...
1039
+ }
117
1040
  ```
118
1041
 
1042
+ > `createDashboardLabels(t)` menggunakan key i18n dari namespace default.
1043
+ > Pastikan file terjemahan kamu memiliki key `admin.dashboard.*`.
1044
+
119
1045
  ---
120
1046
 
121
- ## API Ringkas
1047
+ ## 10. API Reference Lengkap
122
1048
 
123
- ```js
124
- // Config
125
- import { cidikaWidgetConfig, tokoSepatuWidgetConfig, dummyUmkmWidgetConfig }
1049
+ ### Config
1050
+
1051
+ | Export | Tipe | Deskripsi |
1052
+ |--------|------|-----------|
1053
+ | `cidikaWidgetConfig` | Object | Config dashboard Cidika Travel |
1054
+ | `tokoSepatuWidgetConfig` | Object | Config dashboard Toko Sepatu |
1055
+ | `dummyUmkmWidgetConfig` | Object | Config dashboard UMKM generik |
126
1056
 
127
- // Data source
128
- import { createCidikaSupabaseSource, createTokoSepatuSupabaseSource }
1057
+ ### Data Source
129
1058
 
130
- // Adapter
131
- import { adaptCidikaDashboardData, createEmptyDashboardData }
132
- import { adaptTokoSepatuData, createEmptyTokoSepatuData }
133
- import { adaptDummyUmkmData, createEmptyDummyUmkmData }
1059
+ | Export | Parameter | Deskripsi |
1060
+ |--------|-----------|-----------|
1061
+ | `createCidikaSupabaseSource(supabase)` | Supabase client | Data source Cidika Travel |
1062
+ | `createTokoSepatuSupabaseSource(supabase)` | Supabase client | Data source Toko Sepatu |
134
1063
 
135
- // Hook
136
- import { useReusableDashboard, useRealtimeUpdate }
1064
+ ### Data Adapter
137
1065
 
138
- // View
139
- import { ReusableDashboardView }
1066
+ | Export | Parameter | Deskripsi |
1067
+ |--------|-----------|-----------|
1068
+ | `adaptCidikaDashboardData({ raw, filters, range, dateLocale, languageCode, labels })` | — | Adapter Cidika |
1069
+ | `createEmptyDashboardData()` | — | Empty state Cidika |
1070
+ | `adaptTokoSepatuData({ raw, filters, range, dateLocale, labels })` | — | Adapter Toko Sepatu |
1071
+ | `createEmptyTokoSepatuData()` | — | Empty state Toko Sepatu |
1072
+ | `adaptDummyUmkmData({ raw, filters, range, dateLocale, labels })` | — | Adapter UMKM generik |
1073
+ | `createEmptyDummyUmkmData()` | — | Empty state UMKM generik |
140
1074
 
141
- // Utils
142
- import { createDashboardLabels, createDefaultFilters, resolveDateRange }
143
- import { formatIDR, formatDate, shortId, formatYYYYMMDD }
1075
+ ### Hook Utama
1076
+
1077
+ ```typescript
1078
+ const state = useReusableDashboard({
1079
+ config: object, // Widget configuration
1080
+ dataSource: object, // Objek dari createXxxSupabaseSource()
1081
+ adapter: Function, // Fungsi adaptXxxData
1082
+ createEmptyState: Function, // Fungsi createEmptyXxxData
1083
+ languageCode: "id" | "en",
1084
+ dateLocale: "id-ID" | "en-US",
1085
+ labels: object,
1086
+ });
1087
+
1088
+ // state yang dikembalikan:
1089
+ state.data // Data dashboard terproses
1090
+ state.loading // boolean — sedang loading?
1091
+ state.error // string — pesan error (kosong jika tidak ada)
1092
+ state.filters // State filter aktif saat ini
1093
+ state.updateFilter // (field: string, value: any) => void
1094
+ state.resetFilters // () => void
1095
+ state.refresh // ({ silent?: boolean }) => void
1096
+ state.liveUpdateEnabled // boolean — realtime subscription aktif?
1097
+ state.lastUpdatedAt // Date | null
1098
+ state.range // { fromISO, toISO, daysWindow }
144
1099
  ```
145
1100
 
1101
+ ### Komponen
1102
+
1103
+ | Komponen | Deskripsi |
1104
+ |----------|-----------|
1105
+ | `ReusableDashboardView` | Halaman dashboard lengkap — gunakan ini untuk integrasi cepat |
1106
+ | `StatCard` | Kartu metrik dengan warna aksen |
1107
+ | `ChartCard` | Kartu grafik (area/pie/bar) |
1108
+ | `DataTable` | Tabel dengan search, sort, pagination |
1109
+ | `FilterPanel` | Panel filter |
1110
+ | `SearchBar` | Input pencarian |
1111
+ | `DateRangeFilter` | Filter rentang tanggal (dropdown picker) |
1112
+ | `Badge` | Label status berwarna |
1113
+ | `Button` | Tombol |
1114
+ | `Input` | Input field |
1115
+ | `Typography` | Komponen teks |
1116
+ | `Icon` | Ikon (Lucide) |
1117
+ | `SkeletonLoader` | Placeholder loading |
1118
+ | `DashboardLayout` | Template layout dengan slot sidebar dan content |
1119
+ | `SidebarNavigation` | Navigasi sidebar |
1120
+ | `TopbarHeader` | Header atas |
1121
+
1122
+ ### Utility
1123
+
1124
+ | Function | Deskripsi |
1125
+ |----------|-----------|
1126
+ | `createDashboardLabels(t)` | Buat labels dari fungsi `t` react-i18next |
1127
+ | `createDefaultFilters(base?)` | Buat state filter default |
1128
+ | `resolveDateRange({ daysPreset, dateFrom, dateTo })` | Resolve ke `{ fromISO, toISO, daysWindow }` |
1129
+ | `formatYYYYMMDD(date)` | Format `Date` ke `"YYYY-MM-DD"` |
1130
+ | `formatIDR(value)` | Format angka ke format Rupiah (`1.500.000`) |
1131
+ | `formatDate(value, locale)` | Format ISO date string dengan locale |
1132
+ | `shortId(value, length?)` | Potong string (default 8 karakter) |
1133
+ | `toNumber(value)` | Konversi ke angka, fallback ke 0 |
1134
+ | `buildDayBuckets(daysWindow, fromISO, dateLocale)` | Bangun array bucket harian untuk chart |
1135
+ | `sortMapEntries(map, direction)` | Urutkan entri Map berdasarkan nilai |
1136
+
146
1137
  ---
147
1138
 
148
- ## Development
1139
+ ## 11. Pengembangan & Kontribusi
149
1140
 
150
1141
  ```bash
151
- git clone <repo>
1142
+ # Clone repository
1143
+ git clone https://github.com/rozaqi02/reusable-dashboard-umkm.git
152
1144
  cd reusable-dashboard-umkm
1145
+
1146
+ # Install dependencies
153
1147
  npm install
154
- npm run build # output ke dist/
155
- npm test
1148
+
1149
+ # Build
1150
+ npm run build # Output ke dist/
1151
+
1152
+ # Test
1153
+ npm test # Jest + React Testing Library
1154
+ npm test -- --coverage # Lihat code coverage
156
1155
  ```
157
1156
 
1157
+ ### Cara Publish Versi Baru ke npm
1158
+
1159
+ Modul ini publik di npm: `@rozaqi02/reusable-dashboard`. Setiap kali kode modul
1160
+ berubah, kamu wajib publish versi baru agar perubahan sampai ke client.
1161
+
1162
+ ```bash
1163
+ # 1. Pastikan sudah login (sekali saja per device)
1164
+ npm login # buka browser untuk autentikasi
1165
+ npm whoami # verifikasi → harus muncul "rozaqi02"
1166
+
1167
+ # 2. Naikkan versi (pilih salah satu sesuai jenis perubahan)
1168
+ npm version patch # 1.1.2 → 1.1.3 (bug fix / perbaikan kecil)
1169
+ npm version minor # 1.1.2 → 1.2.0 (fitur baru, tanpa breaking change)
1170
+ npm version major # 1.1.2 → 2.0.0 (breaking change)
1171
+
1172
+ # 3. Publish (build otomatis jalan dulu lewat prepublishOnly)
1173
+ npm publish # akan meminta OTP (One-Time Password)
1174
+ ```
1175
+
1176
+ > **Catatan OTP:** karena akun npm kamu mengaktifkan 2FA, `npm publish` akan
1177
+ > meminta one-time password. Masukkan kode dari aplikasi authenticator kamu
1178
+ > (atau gunakan `npm publish --otp=123456`). Langkah ini **harus dijalankan
1179
+ > manual di terminal kamu** — tidak bisa diotomasi.
1180
+
1181
+ > **Publish ≠ git push.** `npm publish` hanya mengunggah isi folder `dist/`
1182
+ > (lihat field `files` di `package.json`) ke registry npm. Tidak bergantung pada
1183
+ > commit/push git terakhir. Jadi kamu tidak perlu push ke GitHub dulu agar
1184
+ > bisa publish — keduanya independen. (Tetap disarankan commit & push agar
1185
+ > source code di GitHub sinkron dengan versi yang dirilis.)
1186
+
1187
+ ### Update Modul di Project Client
1188
+
1189
+ Setelah versi baru terbit, di setiap project yang memakai modul:
1190
+
1191
+ ```bash
1192
+ npm install @rozaqi02/reusable-dashboard@latest
1193
+ ```
1194
+
1195
+ Lalu restart dev server (`npm start` / `npm run dev`) agar perubahan termuat.
1196
+
158
1197
  ---
159
1198
 
160
1199
  ## License
161
1200
 
162
1201
  MIT — Ahmad Abror Rozaqi Fatoni
1202
+
1203
+ ---
1204
+
1205
+ **Made with ❤️ for UMKM Indonesia**