@rozaqi02/reusable-dashboard 1.1.2 → 1.1.4

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
 
@@ -16,13 +16,15 @@ CSS sudah terbundle — tidak perlu konfigurasi Tailwind.
16
16
  2. [Prasyarat Teknis](#2-prasyarat-teknis)
17
17
  3. [Preset yang Tersedia](#3-preset-yang-tersedia)
18
18
  4. [Quick Start — Toko Sepatu (Contoh Lengkap)](#4-quick-start--toko-sepatu)
19
+ - [4.5 Cara Cepat dengan 1 File Config](#45-cara-cepat-dengan-createdasboardconfig)
19
20
  5. [Quick Start — Cidika Travel](#5-quick-start--cidika-travel)
20
21
  6. [Setup Database Supabase](#6-setup-database-supabase)
21
22
  7. [Cara Membuat Adapter untuk Domain Bisnis Baru](#7-cara-membuat-adapter-untuk-domain-bisnis-baru)
22
23
  8. [Konfigurasi Widget](#8-konfigurasi-widget)
23
24
  9. [Label & Internasionalisasi](#9-label--internasionalisasi)
24
25
  10. [API Reference Lengkap](#10-api-reference-lengkap)
25
- 11. [Pengembangan & Kontribusi](#11-pengembangan--kontribusi)
26
+ 11. [Setup Wizard — Panduan Konfigurasi Interaktif](#11-setup-wizard--panduan-konfigurasi-interaktif)
27
+ 12. [Pengembangan & Kontribusi](#12-pengembangan--kontribusi)
26
28
 
27
29
  ---
28
30
 
@@ -33,8 +35,29 @@ npm install @rozaqi02/reusable-dashboard
33
35
  npm install recharts @supabase/supabase-js
34
36
  ```
35
37
 
36
- > **Catatan:** CSS sudah otomatis terbundle di dalam paket ini (`dist/index.css`).
37
- > Tidak perlu menginstall atau mengkonfigurasi Tailwind CSS.
38
+ ### Import CSS
39
+
40
+ CSS modul harus di-import secara eksplisit di file CSS utama project kamu.
41
+
42
+ **Create React App** — tambahkan di `src/index.css`:
43
+ ```css
44
+ @import "../node_modules/@rozaqi02/reusable-dashboard/dist/index.css";
45
+ ```
46
+
47
+ **Vite** — tambahkan di `src/index.css` atau `src/main.jsx`:
48
+ ```css
49
+ /* index.css */
50
+ @import "@rozaqi02/reusable-dashboard/dist/index.css";
51
+ ```
52
+ ```jsx
53
+ // atau di main.jsx
54
+ import "@rozaqi02/reusable-dashboard/dist/index.css";
55
+ ```
56
+
57
+ **Next.js** — tambahkan di `app/globals.css` atau `pages/_app.jsx`:
58
+ ```css
59
+ @import "@rozaqi02/reusable-dashboard/dist/index.css";
60
+ ```
38
61
 
39
62
  ---
40
63
 
@@ -42,8 +65,8 @@ npm install recharts @supabase/supabase-js
42
65
 
43
66
  | Prasyarat | Versi Minimum | Keterangan |
44
67
  |-----------|---------------|------------|
45
- | Node.js | 16.x | Runtime JavaScript |
46
- | React | 18.0.0 | UI framework |
68
+ | Node.js | 18.x | Runtime JavaScript (18 atau lebih baru) |
69
+ | React | 18.0.0 | UI framework (mendukung React 19) |
47
70
  | recharts | 2.0.0 | Library chart (AreaChart, PieChart, BarChart) |
48
71
  | @supabase/supabase-js | 2.0.0 | Client Supabase untuk koneksi database |
49
72
  | Akun Supabase | — | Gratis di [supabase.com](https://supabase.com) |
@@ -57,13 +80,23 @@ Belum mendukung backend lain (Firebase, REST API, GraphQL) tanpa menulis data so
57
80
 
58
81
  Modul menyediakan **3 preset siap pakai** untuk domain bisnis yang berbeda:
59
82
 
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 |
83
+ | Preset | Domain | Tabel Supabase yang Dibutuhkan | Siap Pakai? |
84
+ |--------|--------|-------------------------------|-------------|
85
+ | **Cidika Travel** | Travel agency / paket wisata | `bookings`, `packages`, `package_locales`, `page_sections` | Config + Adapter + Source |
86
+ | **Toko Sepatu** | Toko online / e-commerce | `orders`, `order_items`, `products`, `customers` | Config + Adapter + Source |
87
+ | **Dummy UMKM** | UMKM generik (contoh domain baru) | `orders`, `products`, `customers` | Config + Adapter saja* |
88
+
89
+ > *Preset **Dummy UMKM** menyediakan widget config (`dummyUmkmWidgetConfig`) dan
90
+ > adapter (`adaptDummyUmkmData`), tetapi **tidak** menyertakan data source siap pakai.
91
+ > Data source-nya ditulis manual — lihat contoh di
92
+ > `examples/dummy-umkm-example/DummyUmkmDashboard.jsx` (repo GitHub).
93
+ > Ini sengaja dijadikan **template** untuk kamu yang ingin membuat domain baru
94
+ > (lihat [Bab 7](#7-cara-membuat-adapter-untuk-domain-bisnis-baru)).
65
95
 
66
- Setiap preset sudah termasuk: konfigurasi widget, data adapter, dan data source Supabase.
96
+ Hanya **2 data source siap pakai** yang diekspor modul:
97
+ `createCidikaSupabaseSource` dan `createTokoSepatuSupabaseSource`.
98
+
99
+ Setiap preset siap pakai sudah termasuk: konfigurasi widget, data adapter, dan data source Supabase.
67
100
 
68
101
  ---
69
102
 
@@ -138,8 +171,12 @@ ALTER PUBLICATION supabase_realtime ADD TABLE public.customers;
138
171
 
139
172
  **C. Isi data contoh** (opsional, untuk testing):
140
173
 
141
- File seed data lengkap tersedia di:
142
- `examples/toko-sepatu-example/seed-data.sql` dalam repositori ini.
174
+ File seed data lengkap tersedia di repo GitHub:
175
+ `examples/toko-sepatu-example/seed-data.sql`.
176
+
177
+ > Catatan: folder `examples/` **tidak ikut** saat `npm install` (paket npm hanya
178
+ > berisi `dist/`). Untuk mengambil file SQL contoh, kloning repo GitHub atau
179
+ > salin manual dari halaman repositori.
143
180
 
144
181
  **D. Aktifkan Realtime** di Supabase Dashboard:
145
182
  Database → Replication → centang tabel `orders`, `products`, `customers`.
@@ -278,6 +315,100 @@ Buka browser → dashboard tampil dengan data dari Supabase.
278
315
 
279
316
  ---
280
317
 
318
+ ## 4.5 Cara Cepat dengan `createDashboardConfig`
319
+
320
+ Mulai versi **1.1.3**, modul menyediakan factory function `createDashboardConfig` yang
321
+ mengemas semua konfigurasi (widgetConfig + dataSource + adapter + labels) menjadi
322
+ **1 objek tunggal**. Ini cara yang lebih ringkas dan direkomendasikan untuk domain baru.
323
+
324
+ ```
325
+ Sebelum (4 parameter terpisah ke hook): Sesudah (1 objek terpadu):
326
+
327
+ useReusableDashboard({ const cfg = createDashboardConfig({
328
+ config: tokoSepatuWidgetConfig, widgetConfig: tokoSepatuWidgetConfig,
329
+ dataSource: source, →→→ dataSource: source,
330
+ adapter: adaptTokoSepatuData, adapter: adaptTokoSepatuData,
331
+ createEmptyState: createEmpty, createEmptyState: createEmpty,
332
+ languageCode: "id", languageCode: "id",
333
+ dateLocale: "id-ID", dateLocale: "id-ID",
334
+ labels, });
335
+ }); useReusableDashboard({ ...cfg, labels });
336
+ ```
337
+
338
+ **Contoh penggunaan lengkap:**
339
+
340
+ ```jsx
341
+ import { supabase } from "../lib/supabaseClient";
342
+ import {
343
+ ReusableDashboardView,
344
+ useReusableDashboard,
345
+ createDashboardConfig, // ← factory baru
346
+ tokoSepatuWidgetConfig,
347
+ createTokoSepatuSupabaseSource,
348
+ adaptTokoSepatuData,
349
+ createEmptyTokoSepatuData,
350
+ } from "@rozaqi02/reusable-dashboard";
351
+
352
+ import { myWidgetConfig } from "./myWidgetConfig";
353
+ import { createMySource } from "./myDataSource";
354
+ import { adaptMyData, createEmptyMyData } from "./myAdapter";
355
+
356
+ // Buat di luar komponen — sekali saja
357
+ const dashboardConfig = createDashboardConfig({
358
+ widgetConfig: myWidgetConfig,
359
+ dataSource: createMySource(supabase),
360
+ adapter: adaptMyData,
361
+ createEmptyState: createEmptyMyData,
362
+ languageCode: "id",
363
+ dateLocale: "id-ID",
364
+ // labels bisa disertakan di sini atau di-override saat render
365
+ });
366
+
367
+ const labels = { title: "Dashboard Saya", /* ... */ };
368
+
369
+ export default function MyDashboard() {
370
+ const state = useReusableDashboard({ ...dashboardConfig, labels });
371
+
372
+ return (
373
+ <ReusableDashboardView
374
+ config={dashboardConfig.config}
375
+ labels={labels}
376
+ loading={state.loading}
377
+ error={state.error}
378
+ filters={state.filters}
379
+ onFilterChange={state.updateFilter}
380
+ onResetFilters={state.resetFilters}
381
+ onRefresh={state.refresh}
382
+ data={state.data}
383
+ dateLocale={dashboardConfig.dateLocale}
384
+ liveUpdateEnabled={state.liveUpdateEnabled}
385
+ supabase={supabase} // aktifkan fitur baca tabel di wizard
386
+ dashboardConfig={dashboardConfig} // aktifkan validasi otomatis
387
+ />
388
+ );
389
+ }
390
+ ```
391
+
392
+ **Apa yang terjadi jika config belum lengkap?**
393
+
394
+ `createDashboardConfig` menyimpan info validasi di `dashboardConfig._meta`:
395
+
396
+ ```javascript
397
+ dashboardConfig._meta = {
398
+ isValid: true, // false jika ada bagian yang kurang
399
+ missing: [], // ["adapter", "dataSource"] jika belum diisi
400
+ hasDataSource: true,
401
+ hasRealtimeSupport: true, // false jika subscribeLiveUpdate tidak ada
402
+ hasLabels: true,
403
+ widgetCount: { stats: 4, charts: 4, tableColumns: 6 },
404
+ }
405
+ ```
406
+
407
+ Jika ada bagian yang kurang, `ReusableDashboardView` secara otomatis menampilkan
408
+ **Setup Wizard** (lihat [Bab 11](#11-setup-wizard--panduan-konfigurasi-interaktif)).
409
+
410
+ ---
411
+
281
412
  ## 5. Quick Start — Cidika Travel
282
413
 
283
414
  Ganti hanya 4 baris dari contoh Toko Sepatu di atas:
@@ -378,92 +509,235 @@ CREATE POLICY "Allow read for anon" ON public.orders
378
509
 
379
510
  ## 7. Cara Membuat Adapter untuk Domain Bisnis Baru
380
511
 
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.
512
+ Bab ini adalah panduan **dari nol** untuk client yang domainnya **bukan** travel
513
+ atau toko sepatu (misal: laundry, klinik, rental mobil, kursus, dll). Kamu hanya
514
+ perlu menulis **3 file kecil**, tanpa pernah menyentuh kode inti modul.
515
+
516
+ ### 7.0 Pahami dulu alurnya (WAJIB dibaca)
517
+
518
+ Modul ini memisahkan 3 tanggung jawab. Memahami peran masing-masing membuat
519
+ sisanya gampang:
520
+
521
+ ```
522
+ DATABASE Supabase
523
+
524
+
525
+ ┌───────────────┐ "Ambil data mentah dari Supabase"
526
+ │ 1. DATA SOURCE │ → tahu nama tabel & kolom kamu
527
+ └───────────────┘ → output: objek { bookings, recent, staticCounts }
528
+
529
+
530
+ ┌───────────────┐ "Ubah data mentah → format standar dashboard"
531
+ │ 2. ADAPTER │ → hitung stats, susun data chart & tabel
532
+ └───────────────┘ → output: objek { stats, charts, table }
533
+
534
+
535
+ ┌───────────────┐ "Tentukan WIDGET apa yang tampil & labelnya"
536
+ │ 3. WIDGET │ → kartu mana, chart mana, kolom tabel mana
537
+ │ CONFIG │
538
+ └───────────────┘
539
+
540
+
541
+ useReusableDashboard(...) → <ReusableDashboardView /> (UI jadi)
542
+ ```
543
+
544
+ Aturan emas:
545
+ - **Data Source** = satu-satunya bagian yang tahu nama tabel/kolom database kamu.
546
+ - **Adapter** = satu-satunya bagian yang berisi logika hitung (revenue, konversi, dll).
547
+ - **Widget Config** = satu-satunya bagian yang menentukan tampilan (deklaratif, tanpa logika).
548
+
549
+ Urutan mengerjakan: **Data Source → Adapter → Widget Config → rangkai di halaman**.
550
+
551
+ ---
552
+
553
+ ### 7.1 Kontrak antar-lapisan (referensi cepat)
554
+
555
+ **Output Data Source** (yang masuk ke adapter sebagai `raw`):
556
+
557
+ ```javascript
558
+ {
559
+ bookings: [...], // WAJIB. Array semua transaksi dalam rentang tanggal.
560
+ recent: [...], // WAJIB. Array 10 transaksi terbaru (untuk tabel).
561
+ packageLocales: [], // Opsional. Untuk nama multi-bahasa. Boleh [].
562
+ staticCounts: { // Opsional. Angka agregat (mis. jumlah produk).
563
+ packages: 0,
564
+ sections: 0,
565
+ },
566
+ }
567
+ ```
384
568
 
385
- ### Kontrak data yang harus dikembalikan adapter
569
+ > Nama field **harus** `bookings` dan `recent` walau domainmu bukan booking —
570
+ > ini istilah internal modul. Isi datanya bebas (order, transaksi, reservasi, dll).
571
+
572
+ **Output Adapter** (yang dirender oleh komponen):
386
573
 
387
574
  ```javascript
388
- // Format yang harus dikembalikan oleh adapter
389
575
  {
390
576
  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)
577
+ bookingsConfirm: number, // angka untuk stat card (key bebas, asal cocok config)
578
+ revenueConfirm: number,
579
+ // ...tambahkan key lain sesuai kebutuhan
398
580
  },
399
581
  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
- }
582
+ dailyTrends: [ // area chart tren harian
583
+ { dateKey: "2026-05-01", label: "01 Mei", count: 5, revenue: 2500000, pendingCount: 2 }
408
584
  ],
409
- statusDistribution: [ // untuk pie chart distribusi status
585
+ statusDistribution: [ // pie chart distribusi status
410
586
  { status: "confirmed", label: "Confirmed", count: 30 },
411
- { status: "pending", label: "Pending", count: 10 },
412
587
  ],
413
- audienceDistribution: [], // untuk pie chart distribusi audiens (boleh kosong [])
414
- topPackages: [ // untuk bar chart produk/paket terlaris
588
+ audienceDistribution: [], // pie chart kedua (boleh kosong [])
589
+ topPackages: [ // bar chart terlaris (boleh kosong [])
415
590
  { packageId: "uuid", name: "Nama Produk", value: 10 },
416
591
  ],
417
592
  },
418
593
  table: {
419
- recentBookings: [ // untuk tabel transaksi terbaru
594
+ recentBookings: [ // baris tabel transaksi terbaru
420
595
  {
421
596
  id: "uuid",
422
597
  createdAt: "2026-05-01T10:00:00Z", // ISO string
423
598
  customerName: "Budi Santoso",
424
599
  packageName: "Nama Produk",
425
- audienceLabel: "Domestic", // label audiens (boleh "-")
426
- totalIDR: 500000, // dalam Rupiah (integer)
600
+ audienceLabel: "Domestic", // boleh "-"
601
+ totalIDR: 500000, // integer Rupiah
427
602
  status: "confirmed", // lowercase
428
- statusLabel: "Confirmed", // label untuk ditampilkan
603
+ statusLabel: "Confirmed",
429
604
  }
430
605
  ],
431
606
  },
432
607
  }
433
608
  ```
434
609
 
435
- ### Contoh adapter sederhana
610
+ > Penting: `valueKey` di widget config (Bab 8) harus cocok dengan key di `stats`.
611
+ > Contoh: jika config punya `valueKey: "revenueConfirm"`, adapter wajib
612
+ > mengembalikan `stats.revenueConfirm`.
613
+
614
+ ---
615
+
616
+ ### 7.2 STUDI KASUS: Aplikasi Laundry (dari nol)
617
+
618
+ Misalkan client punya 1 tabel Supabase bernama `laundry_orders`:
619
+
620
+ | Kolom | Tipe | Contoh |
621
+ |-------|------|--------|
622
+ | `id` | uuid | `a1b2...` |
623
+ | `created_at` | timestamptz | `2026-05-01T10:00:00Z` |
624
+ | `customer_name` | text | `Budi Santoso` |
625
+ | `total_price` | integer | `50000` |
626
+ | `status` | text | `confirmed` / `pending` |
627
+ | `service_type` | text | `Cuci Kering` |
628
+
629
+ Target dashboard: 2 stat card (total order, total pendapatan), 2 chart
630
+ (tren harian + distribusi status), dan 1 tabel order terbaru.
631
+
632
+ ---
633
+
634
+ #### Langkah 1 — Tulis DATA SOURCE
635
+
636
+ Buat file `src/datasources/laundryDataSource.js`. Tugasnya: query Supabase,
637
+ kembalikan dalam bentuk `{ bookings, recent, staticCounts }`.
638
+
639
+ ```javascript
640
+ // src/datasources/laundryDataSource.js
641
+ export function createLaundrySupabaseSource(supabase) {
642
+ return {
643
+ // Dipanggil otomatis oleh useReusableDashboard setiap filter berubah.
644
+ // Parameter fromISO/toISO/statusScope sudah dihitung oleh modul.
645
+ async fetchDashboardSnapshot({ fromISO, toISO, statusScope }) {
646
+ // (a) Semua order dalam rentang tanggal → untuk chart & stats
647
+ const { data: orders = [] } = await supabase
648
+ .from("laundry_orders")
649
+ .select("id, created_at, customer_name, total_price, status, service_type")
650
+ .gte("created_at", fromISO)
651
+ .lte("created_at", toISO)
652
+ .order("created_at", { ascending: true });
653
+
654
+ // (b) 10 order terbaru → untuk tabel "Order Terbaru"
655
+ const recentQuery = supabase
656
+ .from("laundry_orders")
657
+ .select("id, created_at, customer_name, total_price, status, service_type")
658
+ .gte("created_at", fromISO)
659
+ .lte("created_at", toISO)
660
+ .order("created_at", { ascending: false })
661
+ .limit(10);
662
+
663
+ // Hormati filter status dari UI (confirmed/pending/all)
664
+ if (statusScope && statusScope !== "all") {
665
+ recentQuery.eq("status", statusScope);
666
+ }
667
+ const { data: recent = [] } = await recentQuery;
668
+
669
+ // (c) Angka agregat opsional, mis. jumlah jenis layanan
670
+ const { count: serviceCount } = await supabase
671
+ .from("services")
672
+ .select("*", { count: "exact", head: true });
673
+
674
+ // Kembalikan SESUAI KONTRAK (nama bookings & recent wajib)
675
+ return {
676
+ bookings: orders,
677
+ recent,
678
+ packageLocales: [],
679
+ staticCounts: { packages: serviceCount || 0, sections: 0 },
680
+ };
681
+ },
682
+
683
+ // OPSIONAL — aktifkan live update realtime. Hapus jika tak perlu.
684
+ subscribeLiveUpdate(onEvent) {
685
+ const channel = supabase
686
+ .channel("laundry-dashboard-live")
687
+ .on("postgres_changes",
688
+ { event: "*", schema: "public", table: "laundry_orders" }, onEvent)
689
+ .subscribe();
690
+ return () => supabase.removeChannel(channel);
691
+ },
692
+ };
693
+ }
694
+ ```
695
+
696
+ > Tips: kalau kamu **tidak** butuh realtime, cukup hapus fungsi
697
+ > `subscribeLiveUpdate`. Badge "Live" otomatis tidak muncul.
698
+
699
+ ---
700
+
701
+ #### Langkah 2 — Tulis ADAPTER
702
+
703
+ Buat file `src/adapters/laundryAdapter.js`. Tugasnya: ubah `raw` dari data source
704
+ menjadi `{ stats, charts, table }`. Pakai helper `toNumber` & `buildDayBuckets`
705
+ dari modul agar tidak menulis ulang logika tanggal.
436
706
 
437
707
  ```javascript
438
708
  // src/adapters/laundryAdapter.js
439
709
  import { toNumber, buildDayBuckets } from "@rozaqi02/reusable-dashboard";
440
710
 
711
+ // (1) State kosong — dipakai modul sebagai initial state & saat error.
712
+ // Strukturnya HARUS sama dengan output adaptLaundryData di bawah.
441
713
  export function createEmptyLaundryData() {
442
714
  return {
443
- stats: { bookingsConfirm: 0, bookingsPending: 0, revenueConfirm: 0,
444
- packages: 0, sections: 0, avgRevenue: 0, conversionRate: 0 },
715
+ stats: { bookingsConfirm: 0, revenueConfirm: 0 },
445
716
  charts: { dailyTrends: [], statusDistribution: [], audienceDistribution: [], topPackages: [] },
446
717
  table: { recentBookings: [] },
447
718
  };
448
719
  }
449
720
 
721
+ // (2) Fungsi adapter utama.
450
722
  export function adaptLaundryData({ raw, filters, range, dateLocale, labels }) {
451
723
  if (!raw) return createEmptyLaundryData();
452
724
 
453
- const orders = raw.bookings || []; // nama field bisa disesuaikan di data source
725
+ const orders = raw.bookings || [];
454
726
  const recent = raw.recent || [];
455
727
 
456
- // Bangun bucket harian
728
+ // Siapkan bucket harian kosong untuk chart tren (1 bucket = 1 hari)
457
729
  const dailyBuckets = buildDayBuckets(range.daysWindow, range.fromISO, dateLocale);
458
- const dayLookup = new Map(dailyBuckets.map(b => [b.dateKey, b]));
730
+ const dayLookup = new Map(dailyBuckets.map((b) => [b.dateKey, b]));
459
731
 
460
- let confirmed = 0, pending = 0, revenue = 0;
461
732
  const statusMap = new Map();
733
+ let confirmed = 0;
734
+ let revenue = 0;
462
735
 
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);
736
+ // Loop semua order: hitung total & isi bucket harian
737
+ orders.forEach((row) => {
738
+ const status = String(row.status || "pending").toLowerCase();
739
+ const amount = toNumber(row.total_price); // ← kolom DB kamu
740
+ const dayKey = String(row.created_at || "").slice(0, 10);
467
741
 
468
742
  statusMap.set(status, (statusMap.get(status) || 0) + 1);
469
743
 
@@ -473,98 +747,211 @@ export function adaptLaundryData({ raw, filters, range, dateLocale, labels }) {
473
747
  bucket.revenue += amount;
474
748
  }
475
749
 
476
- if (status === "confirmed") { confirmed++; revenue += amount; }
477
- else if (status === "pending") pending++;
750
+ if (status === "confirmed") { confirmed += 1; revenue += amount; }
478
751
  });
479
752
 
480
753
  return {
754
+ // stats: key di sini HARUS cocok dengan valueKey di widget config
481
755
  stats: {
482
756
  bookingsConfirm: confirmed,
483
- bookingsPending: pending,
484
757
  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
758
  },
490
759
  charts: {
491
760
  dailyTrends: dailyBuckets,
492
761
  statusDistribution: Array.from(statusMap.entries()).map(([status, count]) => ({
493
- status, label: labels?.formatStatusLabel?.(status) || status, count
762
+ status,
763
+ label: labels?.formatStatusLabel?.(status) || status,
764
+ count,
494
765
  })),
495
- audienceDistribution: [],
496
- topPackages: [],
766
+ audienceDistribution: [], // tidak dipakai laundry → kosong
767
+ topPackages: [], // tidak dipakai laundry → kosong
497
768
  },
498
769
  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
- })),
770
+ recentBookings: recent.map((row) => {
771
+ const status = String(row.status || "pending").toLowerCase();
772
+ return {
773
+ id: row.id,
774
+ createdAt: row.created_at,
775
+ customerName: row.customer_name || "-",
776
+ packageName: row.service_type || "-", // ← kolom DB kamu
777
+ audienceLabel: "-",
778
+ totalIDR: toNumber(row.total_price),
779
+ status,
780
+ statusLabel: labels?.formatStatusLabel?.(status) || status,
781
+ };
782
+ }),
509
783
  },
510
784
  };
511
785
  }
512
786
  ```
513
787
 
514
- ### Contoh data source sederhana
788
+ > Catatan: `audienceDistribution` dan `topPackages` boleh `[]` jika domainmu
789
+ > tidak butuh. Lebih baik lagi: **jangan daftarkan** chart itu di widget config
790
+ > (Langkah 3), supaya tidak tampil sama sekali.
515
791
 
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 });
792
+ ---
528
793
 
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;
794
+ #### Langkah 3 Tulis WIDGET CONFIG
539
795
 
540
- // Hitung jumlah layanan (opsional)
541
- const { count: serviceCount } = await supabase
542
- .from("services")
543
- .select("*", { count: "exact", head: true });
796
+ Buat file `src/config/laundryConfig.js`. Ini murni deklaratif — menentukan
797
+ widget apa yang muncul. `valueKey` harus cocok dengan key `stats` dari adapter,
798
+ dan `label` adalah key dari objek labels (Langkah 4).
544
799
 
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
- };
800
+ ```javascript
801
+ // src/config/laundryConfig.js
802
+ export const laundryConfig = {
803
+ id: "laundry.dashboard",
804
+ defaultFilters: {
805
+ statusScope: "confirmed", // filter awal saat dibuka
806
+ daysPreset: 30, // tampilkan 30 hari terakhir
807
+ sortPkgBy: "bookings",
808
+ sortPkgDir: "desc",
809
+ },
810
+ widgets: {
811
+ // 2 stat card → valueKey cocok dgn stats di adapter
812
+ stats: [
813
+ { id: "orders", label: "confirmedBookings", icon: "TrendingUp",
814
+ valueKey: "bookingsConfirm", format: "number", accentColor: "blue" },
815
+ { id: "revenue", label: "confirmedRevenue", icon: "DollarSign",
816
+ valueKey: "revenueConfirm", format: "currency", accentColor: "green" },
817
+ ],
818
+ // 2 chart → hanya yang ada datanya (tren & status)
819
+ charts: [
820
+ { id: "trend", type: "dailyArea", label: "dailyTrends", icon: "BarChart3" },
821
+ { id: "status", type: "statusPie", label: "statusDistribution", icon: "PieChart" },
822
+ ],
823
+ // 1 tabel → kolom mengacu ke field recentBookings dari adapter
824
+ table: {
825
+ id: "recentOrders",
826
+ label: "recentBookings",
827
+ icon: "Calendar",
828
+ emptyLabel: "noRecentBookings",
829
+ columns: [
830
+ { id: "date", label: "date", accessor: "createdAt", type: "date" },
831
+ { id: "customer", label: "customer", accessor: "customerName" },
832
+ { id: "service", label: "package", accessor: "packageName" },
833
+ { id: "total", label: "total", accessor: "totalIDR", type: "currency" },
834
+ { id: "status", label: "status", accessor: "statusLabel",
835
+ type: "statusBadge", statusAccessor: "status" },
836
+ ],
554
837
  },
838
+ },
839
+ };
840
+ ```
555
841
 
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
- };
842
+ > Detail tiap field config (tipe kolom, nama ikon, format) ada di
843
+ > [Bab 8 — Konfigurasi Widget](#8-konfigurasi-widget).
844
+
845
+ ---
846
+
847
+ #### Langkah 4 — Rangkai semuanya di halaman Dashboard
848
+
849
+ Sekarang gabungkan ke-3 file di atas + objek labels, lalu serahkan ke
850
+ `useReusableDashboard` dan render `ReusableDashboardView`.
851
+
852
+ ```jsx
853
+ // src/pages/LaundryDashboard.jsx
854
+ import React, { useMemo } from "react";
855
+ import { supabase } from "../lib/supabaseClient";
856
+ import {
857
+ ReusableDashboardView,
858
+ useReusableDashboard,
859
+ } from "@rozaqi02/reusable-dashboard";
860
+
861
+ // 3 file yang baru kamu buat:
862
+ import { laundryConfig } from "../config/laundryConfig";
863
+ import { createLaundrySupabaseSource } from "../datasources/laundryDataSource";
864
+ import { adaptLaundryData, createEmptyLaundryData } from "../adapters/laundryAdapter";
865
+
866
+ // Data source dibuat sekali di luar komponen (anti re-render)
867
+ const source = createLaundrySupabaseSource(supabase);
868
+
869
+ // Label UI — semua teks yang tampil. Sesuaikan bahasa/konteks bisnismu.
870
+ const labels = {
871
+ title: "Dashboard Laundry",
872
+ refresh: "Refresh",
873
+ liveUpdate: "Live",
874
+ loadFailed: "Gagal memuat data.",
875
+ retry: "Coba Lagi",
876
+ allStatus: "Semua Status",
877
+ confirmedOnly: "Selesai",
878
+ pendingOnly: "Proses",
879
+ reset: "Reset",
880
+ confirmedBookings: "Total Order",
881
+ confirmedRevenue: "Total Pendapatan",
882
+ dailyTrends: "Tren Harian",
883
+ statusDistribution: "Distribusi Status",
884
+ recentBookings: "Order Terbaru",
885
+ noRecentBookings: "Belum ada order",
886
+ date: "Tanggal",
887
+ customer: "Pelanggan",
888
+ package: "Layanan",
889
+ total: "Total",
890
+ status: "Status",
891
+ bookingsMetric: "Order",
892
+ revenueMetric: "Pendapatan",
893
+ confirmedBookingMetric: "Order (Selesai)",
894
+ confirmedRevenueMetric: "Pendapatan (Selesai)",
895
+ dayLabel: (n) => `${n} hari`,
896
+ formatStatusLabel: (s) =>
897
+ ({ confirmed: "Selesai", pending: "Proses", cancelled: "Batal" })[s] || s,
898
+ formatAudienceLabel: (v) => v || "-",
899
+ };
900
+
901
+ export default function LaundryDashboard() {
902
+ const state = useReusableDashboard({
903
+ config: laundryConfig, // ← Langkah 3
904
+ dataSource: source, // ← Langkah 1
905
+ adapter: adaptLaundryData, // ← Langkah 2
906
+ createEmptyState: createEmptyLaundryData, // ← Langkah 2
907
+ languageCode: "id",
908
+ dateLocale: "id-ID",
909
+ labels,
910
+ });
911
+
912
+ return (
913
+ <ReusableDashboardView
914
+ config={laundryConfig}
915
+ labels={labels}
916
+ loading={state.loading}
917
+ error={state.error}
918
+ filters={state.filters}
919
+ onFilterChange={state.updateFilter}
920
+ onResetFilters={state.resetFilters}
921
+ onRefresh={state.refresh}
922
+ data={state.data}
923
+ dateLocale="id-ID"
924
+ liveUpdateEnabled={state.liveUpdateEnabled}
925
+ />
926
+ );
565
927
  }
566
928
  ```
567
929
 
930
+ Selesai. Buka halaman tersebut → dashboard laundry tampil lengkap dengan data
931
+ dari Supabase, filter tanggal, search, sort, dan pagination — semua sudah
932
+ ditangani modul.
933
+
934
+ ---
935
+
936
+ ### 7.3 Checklist & troubleshooting domain baru
937
+
938
+ | Gejala | Penyebab umum | Solusi |
939
+ |--------|---------------|--------|
940
+ | Stat card menampilkan `0` terus | `valueKey` di config tidak cocok dengan key `stats` adapter | Samakan nama, mis. `valueKey: "revenueConfirm"` ↔ `stats.revenueConfirm` |
941
+ | Chart kosong | Data source tidak mengembalikan `bookings`, atau status bukan `"confirmed"` | Pastikan field bernama `bookings` & status lowercase |
942
+ | Tabel kosong padahal ada data | Data source tidak mengembalikan `recent` | Tambahkan query `recent` (10 terbaru) |
943
+ | Error "is not a function" | Lupa pasang `createEmptyState` | Wajib kirim `createEmptyXxxData` ke hook |
944
+ | Label tampil sebagai key mentah | Key di config (`label`) tidak ada di objek `labels` | Tambahkan key tersebut di objek labels |
945
+ | Badge "Live" tidak muncul | `subscribeLiveUpdate` tidak ada / Realtime belum aktif | Tambahkan fungsi + aktifkan Realtime di Supabase |
946
+
947
+ Checklist final sebelum rilis ke client:
948
+ - [ ] Data source mengembalikan `{ bookings, recent }` (nama persis).
949
+ - [ ] Adapter mengembalikan `{ stats, charts, table }` lengkap.
950
+ - [ ] `createEmptyXxxData` punya struktur sama dengan output adapter.
951
+ - [ ] Semua `valueKey` di config ada di `stats`.
952
+ - [ ] Semua `label`/`emptyLabel` di config ada di objek `labels`.
953
+ - [ ] RLS Supabase mengizinkan `SELECT` untuk role `anon` (lihat [Bab 6](#6-setup-database-supabase)).
954
+
568
955
  ---
569
956
 
570
957
  ## 8. Konfigurasi Widget
@@ -762,6 +1149,20 @@ function Dashboard() {
762
1149
  | `cidikaWidgetConfig` | Object | Config dashboard Cidika Travel |
763
1150
  | `tokoSepatuWidgetConfig` | Object | Config dashboard Toko Sepatu |
764
1151
  | `dummyUmkmWidgetConfig` | Object | Config dashboard UMKM generik |
1152
+ | `createDashboardConfig(options)` | Function | Factory: kemas semua config jadi 1 objek (v1.1.3+) |
1153
+ | `validateDashboardConfig(cfg)` | Function | Validasi config, kembalikan `{ valid, issues }` (v1.1.3+) |
1154
+
1155
+ **`createDashboardConfig(options)` — parameter:**
1156
+
1157
+ | Parameter | Tipe | Wajib | Keterangan |
1158
+ |-----------|------|-------|------------|
1159
+ | `widgetConfig` | Object | ✅ | Konfigurasi widget deklaratif |
1160
+ | `dataSource` | Object | ✅ | Hasil `createXxxSupabaseSource(supabase)` |
1161
+ | `adapter` | Function | ✅ | Fungsi `adaptXxxData` |
1162
+ | `createEmptyState` | Function | ✅ | Fungsi `createEmptyXxxData` |
1163
+ | `labels` | Object | — | Objek label UI (bisa di-override saat render) |
1164
+ | `languageCode` | string | — | Default `"id"` |
1165
+ | `dateLocale` | string | — | Default `"id-ID"` |
765
1166
 
766
1167
  ### Data Source
767
1168
 
@@ -812,6 +1213,7 @@ state.range // { fromISO, toISO, daysWindow }
812
1213
  | Komponen | Deskripsi |
813
1214
  |----------|-----------|
814
1215
  | `ReusableDashboardView` | Halaman dashboard lengkap — gunakan ini untuk integrasi cepat |
1216
+ | `SetupWizard` | Pop-up wizard panduan konfigurasi interaktif (v1.1.3+) |
815
1217
  | `StatCard` | Kartu metrik dengan warna aksen |
816
1218
  | `ChartCard` | Kartu grafik (area/pie/bar) |
817
1219
  | `DataTable` | Tabel dengan search, sort, pagination |
@@ -828,6 +1230,24 @@ state.range // { fromISO, toISO, daysWindow }
828
1230
  | `SidebarNavigation` | Navigasi sidebar |
829
1231
  | `TopbarHeader` | Header atas |
830
1232
 
1233
+ **Props `ReusableDashboardView` (lengkap):**
1234
+
1235
+ | Prop | Tipe | Wajib | Keterangan |
1236
+ |------|------|-------|------------|
1237
+ | `config` | Object | ✅ | Widget configuration |
1238
+ | `labels` | Object | ✅ | Objek label UI |
1239
+ | `loading` | boolean | — | Status loading data |
1240
+ | `error` | string | — | Pesan error |
1241
+ | `filters` | Object | — | State filter aktif |
1242
+ | `onFilterChange` | Function | ✅ | `(field, value) => void` |
1243
+ | `onResetFilters` | Function | ✅ | `() => void` |
1244
+ | `onRefresh` | Function | ✅ | `() => void` |
1245
+ | `data` | Object | — | Data `{ stats, charts, table }` |
1246
+ | `dateLocale` | string | — | Default `"id-ID"` |
1247
+ | `liveUpdateEnabled` | boolean | — | Badge "Live" aktif |
1248
+ | `supabase` | Object | — | Supabase client — aktifkan fitur baca tabel di wizard (v1.1.3+) |
1249
+ | `dashboardConfig` | Object | — | Hasil `createDashboardConfig()` — aktifkan validasi otomatis (v1.1.3+) |
1250
+
831
1251
  ### Utility
832
1252
 
833
1253
  | Function | Deskripsi |
@@ -845,7 +1265,91 @@ state.range // { fromISO, toISO, daysWindow }
845
1265
 
846
1266
  ---
847
1267
 
848
- ## 11. Pengembangan & Kontribusi
1268
+ ## 11. Setup Wizard — Panduan Konfigurasi Interaktif
1269
+
1270
+ Mulai versi **1.1.3**, modul menyertakan **Setup Wizard**: pop-up panduan interaktif yang
1271
+ muncul **otomatis** saat `ReusableDashboardView` mendeteksi konfigurasi belum lengkap.
1272
+
1273
+ Ini menjawab kebutuhan: *"developer baru yang belum pernah pakai modul ini tahu harus
1274
+ mulai dari mana tanpa harus baca README dari awal."*
1275
+
1276
+ ### Cara kerja
1277
+
1278
+ Wizard aktif ketika salah satu kondisi terpenuhi:
1279
+ - Prop `dashboardConfig` (dari `createDashboardConfig`) punya `_meta.isValid === false`
1280
+ - Prop `config`, `labels`, atau `labels.formatStatusLabel` tidak ditemukan
1281
+
1282
+ Wizard **tidak** muncul jika semua konfigurasi sudah valid.
1283
+
1284
+ ### Fitur wizard
1285
+
1286
+ **Step 0 — Overview:**
1287
+ - Checklist masalah yang ditemukan (mis. `"adapter belum diisi"`)
1288
+ - Diagram alur konfigurasi: Data Source → Adapter → Widget Config
1289
+
1290
+ **Step 1 — Data Source:**
1291
+ - Contoh kode `fetchDashboardSnapshot` yang siap di-copy
1292
+ - Tombol **"Baca tabel Supabase langsung"** — membaca daftar tabel dari project
1293
+ Supabase kamu secara realtime via `information_schema.tables`
1294
+
1295
+ **Step 2 — Adapter:**
1296
+ - Contoh kode `adaptMyData` dengan `buildDayBuckets` + `toNumber`
1297
+ - Penjelasan bahwa key `stats` harus cocok dengan `valueKey` di widget config
1298
+
1299
+ **Step 3 — Widget Config & Rangkaian Akhir:**
1300
+ - Contoh kode lengkap dengan `createDashboardConfig` dari langkah 1–3
1301
+
1302
+ ### Cara mengaktifkan fitur baca tabel Supabase
1303
+
1304
+ Tambahkan prop `supabase` ke `ReusableDashboardView`:
1305
+
1306
+ ```jsx
1307
+ <ReusableDashboardView
1308
+ config={dashboardConfig.config}
1309
+ labels={labels}
1310
+ // ... props lain
1311
+ supabase={supabase} // ← aktifkan fitur baca tabel
1312
+ dashboardConfig={dashboardConfig} // ← aktifkan validasi otomatis
1313
+ />
1314
+ ```
1315
+
1316
+ Saat wizard terbuka di Step 1, pengguna bisa klik tombol untuk melihat
1317
+ daftar tabel yang tersedia di project Supabase mereka:
1318
+
1319
+ ```
1320
+ Tabel ditemukan (4):
1321
+ bookings packages package_locales page_sections
1322
+ ```
1323
+
1324
+ > Fitur ini membutuhkan RLS Supabase mengizinkan `SELECT` pada
1325
+ > `information_schema.tables` untuk role `anon`, atau fallback ke RPC `get_tables`.
1326
+ > Jika tidak bisa diakses, wizard tetap berfungsi — hanya tombol baca tabel yang
1327
+ > menampilkan pesan error.
1328
+
1329
+ ### Menggunakan wizard tanpa `createDashboardConfig`
1330
+
1331
+ Wizard juga bisa dipakai standalone (misalnya saat testing atau demo):
1332
+
1333
+ ```jsx
1334
+ import { SetupWizard } from "@rozaqi02/reusable-dashboard";
1335
+
1336
+ // Tampilkan wizard dengan isu kustom
1337
+ <SetupWizard
1338
+ issues={["Widget config belum diisi", "Data source tidak ditemukan"]}
1339
+ supabase={supabase}
1340
+ onDismiss={() => console.log("wizard ditutup")}
1341
+ />
1342
+ ```
1343
+
1344
+ ### Menutup wizard
1345
+
1346
+ Klik tombol **"Lanjutkan tanpa wizard"** atau **"Tutup & lanjutkan ✓"** di Step 3.
1347
+ Wizard tidak muncul lagi selama sesi berlangsung (state `wizardDismissed` disimpan
1348
+ di level komponen).
1349
+
1350
+ ---
1351
+
1352
+ ## 12. Pengembangan & Kontribusi
849
1353
 
850
1354
  ```bash
851
1355
  # Clone repository
@@ -861,13 +1365,48 @@ npm run build # Output ke dist/
861
1365
  # Test
862
1366
  npm test # Jest + React Testing Library
863
1367
  npm test -- --coverage # Lihat code coverage
1368
+ ```
1369
+
1370
+ ### Cara Publish Versi Baru ke npm
1371
+
1372
+ Modul ini publik di npm: `@rozaqi02/reusable-dashboard`. Setiap kali kode modul
1373
+ berubah, kamu wajib publish versi baru agar perubahan sampai ke client.
1374
+
1375
+ ```bash
1376
+ # 1. Pastikan sudah login (sekali saja per device)
1377
+ npm login # buka browser untuk autentikasi
1378
+ npm whoami # verifikasi → harus muncul "rozaqi02"
864
1379
 
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
1380
+ # 2. Naikkan versi (pilih salah satu sesuai jenis perubahan)
1381
+ npm version patch # 1.1.2 → 1.1.3 (bug fix / perbaikan kecil)
1382
+ npm version minor # 1.1.2 → 1.2.0 (fitur baru, tanpa breaking change)
1383
+ npm version major # 1.1.2 2.0.0 (breaking change)
1384
+
1385
+ # 3. Publish (build otomatis jalan dulu lewat prepublishOnly)
1386
+ npm publish # akan meminta OTP (One-Time Password)
869
1387
  ```
870
1388
 
1389
+ > **Catatan OTP:** karena akun npm kamu mengaktifkan 2FA, `npm publish` akan
1390
+ > meminta one-time password. Masukkan kode dari aplikasi authenticator kamu
1391
+ > (atau gunakan `npm publish --otp=123456`). Langkah ini **harus dijalankan
1392
+ > manual di terminal kamu** — tidak bisa diotomasi.
1393
+
1394
+ > **Publish ≠ git push.** `npm publish` hanya mengunggah isi folder `dist/`
1395
+ > (lihat field `files` di `package.json`) ke registry npm. Tidak bergantung pada
1396
+ > commit/push git terakhir. Jadi kamu tidak perlu push ke GitHub dulu agar
1397
+ > bisa publish — keduanya independen. (Tetap disarankan commit & push agar
1398
+ > source code di GitHub sinkron dengan versi yang dirilis.)
1399
+
1400
+ ### Update Modul di Project Client
1401
+
1402
+ Setelah versi baru terbit, di setiap project yang memakai modul:
1403
+
1404
+ ```bash
1405
+ npm install @rozaqi02/reusable-dashboard@latest
1406
+ ```
1407
+
1408
+ Lalu restart dev server (`npm start` / `npm run dev`) agar perubahan termuat.
1409
+
871
1410
  ---
872
1411
 
873
1412
  ## License