@rozaqi02/reusable-dashboard 1.1.2 → 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
@@ -4,7 +4,7 @@ Modul dashboard admin reusable untuk aplikasi UMKM berbasis React + Supabase.
4
4
  Mendukung berbagai domain bisnis (travel, toko online, UMKM generik) tanpa menulis ulang komponen.
5
5
  CSS sudah terbundle — tidak perlu konfigurasi Tailwind.
6
6
 
7
- [![version](https://img.shields.io/badge/version-1.1.1-blue)](./CHANGELOG.md)
7
+ [![version](https://img.shields.io/badge/version-1.1.3-blue)](./CHANGELOG.md)
8
8
  [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
9
9
  [![npm](https://img.shields.io/badge/npm-%40rozaqi02%2Freusable--dashboard-red)](https://www.npmjs.com/package/@rozaqi02/reusable-dashboard)
10
10
 
@@ -33,8 +33,29 @@ npm install @rozaqi02/reusable-dashboard
33
33
  npm install recharts @supabase/supabase-js
34
34
  ```
35
35
 
36
- > **Catatan:** CSS sudah otomatis terbundle di dalam paket ini (`dist/index.css`).
37
- > Tidak perlu menginstall atau mengkonfigurasi Tailwind CSS.
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
+ ```
38
59
 
39
60
  ---
40
61
 
@@ -42,8 +63,8 @@ npm install recharts @supabase/supabase-js
42
63
 
43
64
  | Prasyarat | Versi Minimum | Keterangan |
44
65
  |-----------|---------------|------------|
45
- | Node.js | 16.x | Runtime JavaScript |
46
- | React | 18.0.0 | UI framework |
66
+ | Node.js | 18.x | Runtime JavaScript (18 atau lebih baru) |
67
+ | React | 18.0.0 | UI framework (mendukung React 19) |
47
68
  | recharts | 2.0.0 | Library chart (AreaChart, PieChart, BarChart) |
48
69
  | @supabase/supabase-js | 2.0.0 | Client Supabase untuk koneksi database |
49
70
  | Akun Supabase | — | Gratis di [supabase.com](https://supabase.com) |
@@ -57,13 +78,23 @@ Belum mendukung backend lain (Firebase, REST API, GraphQL) tanpa menulis data so
57
78
 
58
79
  Modul menyediakan **3 preset siap pakai** untuk domain bisnis yang berbeda:
59
80
 
60
- | Preset | Domain | Tabel Supabase yang Dibutuhkan |
61
- |--------|--------|-------------------------------|
62
- | **Cidika Travel** | Travel agency / paket wisata | `bookings`, `packages`, `package_locales`, `page_sections` |
63
- | **Toko Sepatu** | Toko online / e-commerce | `orders`, `order_items`, `products`, `customers` |
64
- | **Dummy UMKM** | UMKM generik | Bisa pakai struktur toko sepatu atau buat sendiri |
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`.
65
96
 
66
- Setiap preset sudah termasuk: konfigurasi widget, data adapter, dan data source Supabase.
97
+ Setiap preset siap pakai sudah termasuk: konfigurasi widget, data adapter, dan data source Supabase.
67
98
 
68
99
  ---
69
100
 
@@ -138,8 +169,12 @@ ALTER PUBLICATION supabase_realtime ADD TABLE public.customers;
138
169
 
139
170
  **C. Isi data contoh** (opsional, untuk testing):
140
171
 
141
- File seed data lengkap tersedia di:
142
- `examples/toko-sepatu-example/seed-data.sql` dalam repositori ini.
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.
143
178
 
144
179
  **D. Aktifkan Realtime** di Supabase Dashboard:
145
180
  Database → Replication → centang tabel `orders`, `products`, `customers`.
@@ -378,92 +413,235 @@ CREATE POLICY "Allow read for anon" ON public.orders
378
413
 
379
414
  ## 7. Cara Membuat Adapter untuk Domain Bisnis Baru
380
415
 
381
- Jika bisnis kamu tidak cocok dengan preset yang ada, kamu bisa membuat
382
- adapter sendiri. Adapter adalah fungsi JavaScript yang mengubah data mentah
383
- dari Supabase ke format yang dipahami oleh komponen dashboard.
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)
384
421
 
385
- ### Kontrak data yang harus dikembalikan adapter
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):
386
477
 
387
478
  ```javascript
388
- // Format yang harus dikembalikan oleh adapter
389
479
  {
390
480
  stats: {
391
- bookingsConfirm: number, // jumlah transaksi confirmed
392
- bookingsPending: number, // jumlah transaksi pending
393
- revenueConfirm: number, // total pendapatan (dalam Rupiah, integer)
394
- packages: number, // jumlah produk/paket
395
- sections: number, // jumlah kategori/section (opsional, bisa 0)
396
- avgRevenue: number, // rata-rata pendapatan per transaksi
397
- conversionRate: number, // persentase konversi (0–100)
481
+ bookingsConfirm: number, // angka untuk stat card (key bebas, asal cocok config)
482
+ revenueConfirm: number,
483
+ // ...tambahkan key lain sesuai kebutuhan
398
484
  },
399
485
  charts: {
400
- dailyTrends: [ // untuk area chart tren harian
401
- {
402
- dateKey: "2026-05-01", // format YYYY-MM-DD
403
- label: "01 Mei", // label untuk sumbu X chart
404
- count: 5, // jumlah transaksi confirmed
405
- revenue: 2500000, // total revenue
406
- pendingCount: 2, // jumlah pending (untuk overlay)
407
- }
486
+ dailyTrends: [ // area chart tren harian
487
+ { dateKey: "2026-05-01", label: "01 Mei", count: 5, revenue: 2500000, pendingCount: 2 }
408
488
  ],
409
- statusDistribution: [ // untuk pie chart distribusi status
489
+ statusDistribution: [ // pie chart distribusi status
410
490
  { status: "confirmed", label: "Confirmed", count: 30 },
411
- { status: "pending", label: "Pending", count: 10 },
412
491
  ],
413
- audienceDistribution: [], // untuk pie chart distribusi audiens (boleh kosong [])
414
- topPackages: [ // untuk bar chart produk/paket terlaris
492
+ audienceDistribution: [], // pie chart kedua (boleh kosong [])
493
+ topPackages: [ // bar chart terlaris (boleh kosong [])
415
494
  { packageId: "uuid", name: "Nama Produk", value: 10 },
416
495
  ],
417
496
  },
418
497
  table: {
419
- recentBookings: [ // untuk tabel transaksi terbaru
498
+ recentBookings: [ // baris tabel transaksi terbaru
420
499
  {
421
500
  id: "uuid",
422
501
  createdAt: "2026-05-01T10:00:00Z", // ISO string
423
502
  customerName: "Budi Santoso",
424
503
  packageName: "Nama Produk",
425
- audienceLabel: "Domestic", // label audiens (boleh "-")
426
- totalIDR: 500000, // dalam Rupiah (integer)
504
+ audienceLabel: "Domestic", // boleh "-"
505
+ totalIDR: 500000, // integer Rupiah
427
506
  status: "confirmed", // lowercase
428
- statusLabel: "Confirmed", // label untuk ditampilkan
507
+ statusLabel: "Confirmed",
429
508
  }
430
509
  ],
431
510
  },
432
511
  }
433
512
  ```
434
513
 
435
- ### Contoh adapter sederhana
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.
436
610
 
437
611
  ```javascript
438
612
  // src/adapters/laundryAdapter.js
439
613
  import { toNumber, buildDayBuckets } from "@rozaqi02/reusable-dashboard";
440
614
 
615
+ // (1) State kosong — dipakai modul sebagai initial state & saat error.
616
+ // Strukturnya HARUS sama dengan output adaptLaundryData di bawah.
441
617
  export function createEmptyLaundryData() {
442
618
  return {
443
- stats: { bookingsConfirm: 0, bookingsPending: 0, revenueConfirm: 0,
444
- packages: 0, sections: 0, avgRevenue: 0, conversionRate: 0 },
619
+ stats: { bookingsConfirm: 0, revenueConfirm: 0 },
445
620
  charts: { dailyTrends: [], statusDistribution: [], audienceDistribution: [], topPackages: [] },
446
621
  table: { recentBookings: [] },
447
622
  };
448
623
  }
449
624
 
625
+ // (2) Fungsi adapter utama.
450
626
  export function adaptLaundryData({ raw, filters, range, dateLocale, labels }) {
451
627
  if (!raw) return createEmptyLaundryData();
452
628
 
453
- const orders = raw.bookings || []; // nama field bisa disesuaikan di data source
629
+ const orders = raw.bookings || [];
454
630
  const recent = raw.recent || [];
455
631
 
456
- // Bangun bucket harian
632
+ // Siapkan bucket harian kosong untuk chart tren (1 bucket = 1 hari)
457
633
  const dailyBuckets = buildDayBuckets(range.daysWindow, range.fromISO, dateLocale);
458
- const dayLookup = new Map(dailyBuckets.map(b => [b.dateKey, b]));
634
+ const dayLookup = new Map(dailyBuckets.map((b) => [b.dateKey, b]));
459
635
 
460
- let confirmed = 0, pending = 0, revenue = 0;
461
636
  const statusMap = new Map();
637
+ let confirmed = 0;
638
+ let revenue = 0;
462
639
 
463
- orders.forEach(row => {
464
- const status = (row.status || "pending").toLowerCase();
465
- const amount = toNumber(row.total_price); // sesuaikan nama kolom
466
- const dayKey = row.created_at?.slice(0, 10);
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);
467
645
 
468
646
  statusMap.set(status, (statusMap.get(status) || 0) + 1);
469
647
 
@@ -473,98 +651,211 @@ export function adaptLaundryData({ raw, filters, range, dateLocale, labels }) {
473
651
  bucket.revenue += amount;
474
652
  }
475
653
 
476
- if (status === "confirmed") { confirmed++; revenue += amount; }
477
- else if (status === "pending") pending++;
654
+ if (status === "confirmed") { confirmed += 1; revenue += amount; }
478
655
  });
479
656
 
480
657
  return {
658
+ // stats: key di sini HARUS cocok dengan valueKey di widget config
481
659
  stats: {
482
660
  bookingsConfirm: confirmed,
483
- bookingsPending: pending,
484
661
  revenueConfirm: revenue,
485
- packages: toNumber(raw.staticCounts?.packages),
486
- sections: 0,
487
- avgRevenue: confirmed > 0 ? Math.round(revenue / confirmed) : 0,
488
- conversionRate: confirmed + pending > 0 ? Math.round(confirmed / (confirmed + pending) * 100) : 0,
489
662
  },
490
663
  charts: {
491
664
  dailyTrends: dailyBuckets,
492
665
  statusDistribution: Array.from(statusMap.entries()).map(([status, count]) => ({
493
- status, label: labels?.formatStatusLabel?.(status) || status, count
666
+ status,
667
+ label: labels?.formatStatusLabel?.(status) || status,
668
+ count,
494
669
  })),
495
- audienceDistribution: [],
496
- topPackages: [],
670
+ audienceDistribution: [], // tidak dipakai laundry → kosong
671
+ topPackages: [], // tidak dipakai laundry → kosong
497
672
  },
498
673
  table: {
499
- recentBookings: recent.map(row => ({
500
- id: row.id,
501
- createdAt: row.created_at,
502
- customerName: row.customer_name || "-",
503
- packageName: row.service_type || "-", // sesuaikan nama kolom
504
- audienceLabel: "-",
505
- totalIDR: toNumber(row.total_price),
506
- status: (row.status || "pending").toLowerCase(),
507
- statusLabel: labels?.formatStatusLabel?.(row.status) || row.status,
508
- })),
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
+ }),
509
687
  },
510
688
  };
511
689
  }
512
690
  ```
513
691
 
514
- ### Contoh data source sederhana
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.
515
695
 
516
- ```javascript
517
- // src/datasources/laundryDataSource.js
518
- export function createLaundrySupabaseSource(supabase) {
519
- return {
520
- async fetchDashboardSnapshot({ fromISO, toISO, statusScope }) {
521
- // Query utama
522
- const { data: orders = [] } = await supabase
523
- .from("laundry_orders") // sesuaikan nama tabel
524
- .select("id, created_at, customer_name, total_price, status, service_type")
525
- .gte("created_at", fromISO)
526
- .lte("created_at", toISO)
527
- .order("created_at", { ascending: true });
696
+ ---
528
697
 
529
- // Query recent (10 terbaru)
530
- const recentQuery = supabase
531
- .from("laundry_orders")
532
- .select("id, created_at, customer_name, total_price, status, service_type")
533
- .gte("created_at", fromISO)
534
- .lte("created_at", toISO)
535
- .order("created_at", { ascending: false })
536
- .limit(10);
537
- if (statusScope && statusScope !== "all") recentQuery.eq("status", statusScope);
538
- const { data: recent = [] } = await recentQuery;
698
+ #### Langkah 3 Tulis WIDGET CONFIG
539
699
 
540
- // Hitung jumlah layanan (opsional)
541
- const { count: serviceCount } = await supabase
542
- .from("services")
543
- .select("*", { count: "exact", head: true });
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).
544
703
 
545
- return {
546
- bookings: orders, // wajib, nama harus "bookings"
547
- recent, // wajib, nama harus "recent"
548
- packageLocales: [], // kosong jika tidak ada multi-bahasa
549
- staticCounts: {
550
- packages: serviceCount || 0,
551
- sections: 0,
552
- },
553
- };
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
+ ],
554
741
  },
742
+ },
743
+ };
744
+ ```
555
745
 
556
- // Opsional: subscribe ke perubahan realtime
557
- subscribeLiveUpdate(onEvent) {
558
- const channel = supabase
559
- .channel("laundry-dashboard-live")
560
- .on("postgres_changes", { event: "*", schema: "public", table: "laundry_orders" }, onEvent)
561
- .subscribe();
562
- return () => supabase.removeChannel(channel);
563
- },
564
- };
746
+ > Detail tiap field config (tipe kolom, nama ikon, format) ada di
747
+ > [Bab 8 — Konfigurasi Widget](#8-konfigurasi-widget).
748
+
749
+ ---
750
+
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
+ );
565
831
  }
566
832
  ```
567
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
+
568
859
  ---
569
860
 
570
861
  ## 8. Konfigurasi Widget
@@ -861,13 +1152,48 @@ npm run build # Output ke dist/
861
1152
  # Test
862
1153
  npm test # Jest + React Testing Library
863
1154
  npm test -- --coverage # Lihat code coverage
1155
+ ```
864
1156
 
865
- # Publish versi baru
866
- npm version patch # 1.0.0 → 1.0.1
867
- npm version minor # 1.0.0 1.1.0
868
- npm publish # Build otomatis sebelum publish
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)
869
1174
  ```
870
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
+
871
1197
  ---
872
1198
 
873
1199
  ## License