@rozaqi02/reusable-dashboard 1.0.0
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +571 -0
- package/dist/index.cjs +2340 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2293 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2293 @@
|
|
|
1
|
+
// src/config/cidikaWidgetConfig.js
|
|
2
|
+
var cidikaWidgetConfig = {
|
|
3
|
+
id: "cidika.travel.dashboard",
|
|
4
|
+
defaultFilters: {
|
|
5
|
+
statusScope: "confirmed",
|
|
6
|
+
includePendingOverlay: false,
|
|
7
|
+
audience: "",
|
|
8
|
+
daysPreset: 30,
|
|
9
|
+
sortPkgBy: "bookings",
|
|
10
|
+
sortPkgDir: "desc"
|
|
11
|
+
},
|
|
12
|
+
widgets: {
|
|
13
|
+
stats: [
|
|
14
|
+
{
|
|
15
|
+
id: "bookingsConfirm",
|
|
16
|
+
label: "confirmedBookings",
|
|
17
|
+
icon: "TrendingUp",
|
|
18
|
+
valueKey: "bookingsConfirm",
|
|
19
|
+
format: "number"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "revenueConfirm",
|
|
23
|
+
label: "confirmedRevenue",
|
|
24
|
+
icon: "DollarSign",
|
|
25
|
+
valueKey: "revenueConfirm",
|
|
26
|
+
format: "currency"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "avgRevenue",
|
|
30
|
+
label: "avgRevenue",
|
|
31
|
+
icon: "Users",
|
|
32
|
+
valueKey: "avgRevenue",
|
|
33
|
+
format: "currency"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "conversionRate",
|
|
37
|
+
label: "conversionRate",
|
|
38
|
+
icon: "PieChart",
|
|
39
|
+
valueKey: "conversionRate",
|
|
40
|
+
format: "percent"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
charts: [
|
|
44
|
+
{
|
|
45
|
+
id: "dailyTrends",
|
|
46
|
+
type: "dailyArea",
|
|
47
|
+
label: "dailyTrends",
|
|
48
|
+
icon: "BarChart3"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "statusDistribution",
|
|
52
|
+
type: "statusPie",
|
|
53
|
+
label: "statusDistribution",
|
|
54
|
+
icon: "PieChart"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "audienceDistribution",
|
|
58
|
+
type: "audiencePie",
|
|
59
|
+
label: "audienceDistribution",
|
|
60
|
+
icon: "Users"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "topPackages",
|
|
64
|
+
type: "topPackagesBar",
|
|
65
|
+
label: "topPackages",
|
|
66
|
+
icon: "TrendingUp"
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
table: {
|
|
70
|
+
id: "recentBookings",
|
|
71
|
+
label: "recentBookings",
|
|
72
|
+
icon: "Calendar",
|
|
73
|
+
emptyLabel: "noRecentBookings",
|
|
74
|
+
columns: [
|
|
75
|
+
{ id: "date", label: "date", accessor: "createdAt", type: "date" },
|
|
76
|
+
{ id: "customer", label: "customer", accessor: "customerName" },
|
|
77
|
+
{ id: "package", label: "package", accessor: "packageName" },
|
|
78
|
+
{ id: "audience", label: "audience", accessor: "audienceLabel" },
|
|
79
|
+
{ id: "total", label: "total", accessor: "totalIDR", type: "currency" },
|
|
80
|
+
{
|
|
81
|
+
id: "status",
|
|
82
|
+
label: "status",
|
|
83
|
+
accessor: "statusLabel",
|
|
84
|
+
type: "statusBadge",
|
|
85
|
+
statusAccessor: "status"
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/config/dummyUmkmWidgetConfig.js
|
|
93
|
+
var dummyUmkmWidgetConfig = {
|
|
94
|
+
id: "dummy.umkm.dashboard",
|
|
95
|
+
defaultFilters: {
|
|
96
|
+
statusScope: "all",
|
|
97
|
+
includePendingOverlay: false,
|
|
98
|
+
audience: "",
|
|
99
|
+
daysPreset: 30,
|
|
100
|
+
sortPkgBy: "revenue",
|
|
101
|
+
sortPkgDir: "desc"
|
|
102
|
+
},
|
|
103
|
+
widgets: {
|
|
104
|
+
stats: [
|
|
105
|
+
{
|
|
106
|
+
id: "orders",
|
|
107
|
+
label: "confirmedBookings",
|
|
108
|
+
icon: "TrendingUp",
|
|
109
|
+
valueKey: "bookingsConfirm",
|
|
110
|
+
format: "number"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "revenue",
|
|
114
|
+
label: "confirmedRevenue",
|
|
115
|
+
icon: "DollarSign",
|
|
116
|
+
valueKey: "revenueConfirm",
|
|
117
|
+
format: "currency"
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
charts: [
|
|
121
|
+
{
|
|
122
|
+
id: "revenueTrend",
|
|
123
|
+
type: "dailyArea",
|
|
124
|
+
label: "dailyTrends",
|
|
125
|
+
icon: "BarChart3"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "recentOrderStatus",
|
|
129
|
+
type: "statusPie",
|
|
130
|
+
label: "statusDistribution",
|
|
131
|
+
icon: "PieChart"
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
table: {
|
|
135
|
+
id: "recentOrders",
|
|
136
|
+
label: "recentBookings",
|
|
137
|
+
icon: "Calendar",
|
|
138
|
+
emptyLabel: "noRecentBookings",
|
|
139
|
+
columns: [
|
|
140
|
+
{ id: "date", label: "date", accessor: "createdAt", type: "date" },
|
|
141
|
+
{ id: "customer", label: "customer", accessor: "customerName" },
|
|
142
|
+
{ id: "total", label: "total", accessor: "totalIDR", type: "currency" }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// src/config/tokoSepatuWidgetConfig.js
|
|
149
|
+
var tokoSepatuWidgetConfig = {
|
|
150
|
+
id: "toko.sepatu.dashboard",
|
|
151
|
+
defaultFilters: {
|
|
152
|
+
statusScope: "all",
|
|
153
|
+
includePendingOverlay: false,
|
|
154
|
+
audience: "",
|
|
155
|
+
daysPreset: 30,
|
|
156
|
+
sortPkgBy: "revenue",
|
|
157
|
+
sortPkgDir: "desc"
|
|
158
|
+
},
|
|
159
|
+
widgets: {
|
|
160
|
+
stats: [
|
|
161
|
+
{
|
|
162
|
+
id: "totalOrders",
|
|
163
|
+
label: "confirmedBookings",
|
|
164
|
+
icon: "TrendingUp",
|
|
165
|
+
valueKey: "bookingsConfirm",
|
|
166
|
+
format: "number"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "totalRevenue",
|
|
170
|
+
label: "confirmedRevenue",
|
|
171
|
+
icon: "DollarSign",
|
|
172
|
+
valueKey: "revenueConfirm",
|
|
173
|
+
format: "currency"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "avgOrderValue",
|
|
177
|
+
label: "avgRevenue",
|
|
178
|
+
icon: "BarChart3",
|
|
179
|
+
valueKey: "avgRevenue",
|
|
180
|
+
format: "currency"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "totalProducts",
|
|
184
|
+
label: "totalProducts",
|
|
185
|
+
icon: "PieChart",
|
|
186
|
+
valueKey: "packages",
|
|
187
|
+
format: "number"
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
charts: [
|
|
191
|
+
{
|
|
192
|
+
id: "dailyOrderTrends",
|
|
193
|
+
type: "dailyArea",
|
|
194
|
+
label: "dailyTrends",
|
|
195
|
+
icon: "BarChart3"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "orderStatusDistribution",
|
|
199
|
+
type: "statusPie",
|
|
200
|
+
label: "statusDistribution",
|
|
201
|
+
icon: "PieChart"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "topProducts",
|
|
205
|
+
type: "topPackagesBar",
|
|
206
|
+
label: "topPackages",
|
|
207
|
+
icon: "TrendingUp"
|
|
208
|
+
}
|
|
209
|
+
],
|
|
210
|
+
table: {
|
|
211
|
+
id: "recentOrders",
|
|
212
|
+
label: "recentBookings",
|
|
213
|
+
icon: "Calendar",
|
|
214
|
+
emptyLabel: "noRecentBookings",
|
|
215
|
+
columns: [
|
|
216
|
+
{ id: "date", label: "date", accessor: "createdAt", type: "date" },
|
|
217
|
+
{ id: "customer", label: "customer", accessor: "customerName" },
|
|
218
|
+
{ id: "product", label: "package", accessor: "packageName" },
|
|
219
|
+
{ id: "total", label: "total", accessor: "totalIDR", type: "currency" },
|
|
220
|
+
{
|
|
221
|
+
id: "status",
|
|
222
|
+
label: "status",
|
|
223
|
+
accessor: "statusLabel",
|
|
224
|
+
type: "statusBadge",
|
|
225
|
+
statusAccessor: "status"
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/utils/formatters.js
|
|
233
|
+
function formatIDR(value) {
|
|
234
|
+
try {
|
|
235
|
+
return (Number(value) || 0).toLocaleString("id-ID");
|
|
236
|
+
} catch (_error) {
|
|
237
|
+
return String(value ?? 0);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function shortId(value, length = 8) {
|
|
241
|
+
if (!value) return "-";
|
|
242
|
+
return String(value).slice(0, length);
|
|
243
|
+
}
|
|
244
|
+
function formatDate(value, locale) {
|
|
245
|
+
if (!value) return "-";
|
|
246
|
+
const date = new Date(value);
|
|
247
|
+
if (Number.isNaN(date.getTime())) return "-";
|
|
248
|
+
return date.toLocaleDateString(locale);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/utils/adapterHelpers.js
|
|
252
|
+
function toNumber(value) {
|
|
253
|
+
const parsed = Number(value);
|
|
254
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
255
|
+
}
|
|
256
|
+
function buildDayBuckets(daysWindow, fromISO, dateLocale) {
|
|
257
|
+
const start = new Date(fromISO);
|
|
258
|
+
return Array.from({ length: daysWindow }).map((_, index) => {
|
|
259
|
+
const day = new Date(start);
|
|
260
|
+
day.setDate(start.getDate() + index);
|
|
261
|
+
return {
|
|
262
|
+
dateKey: day.toISOString().slice(0, 10),
|
|
263
|
+
label: day.toLocaleDateString(dateLocale, {
|
|
264
|
+
day: "2-digit",
|
|
265
|
+
month: "short"
|
|
266
|
+
}),
|
|
267
|
+
count: 0,
|
|
268
|
+
revenue: 0,
|
|
269
|
+
pendingCount: 0
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
function sortMapEntries(map, direction) {
|
|
274
|
+
return Array.from(map.entries()).sort(
|
|
275
|
+
(a, b) => direction === "asc" ? a[1] - b[1] : b[1] - a[1]
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/data-adapter/cidikaDashboardAdapter.js
|
|
280
|
+
function createPackageTitleResolver(packageLocales, languageCode) {
|
|
281
|
+
const byPackage = /* @__PURE__ */ new Map();
|
|
282
|
+
packageLocales.forEach((row) => {
|
|
283
|
+
if (!row.package_id) return;
|
|
284
|
+
const current = byPackage.get(row.package_id) || {};
|
|
285
|
+
byPackage.set(row.package_id, {
|
|
286
|
+
...current,
|
|
287
|
+
[row.lang]: row.title
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
return (packageId) => {
|
|
291
|
+
if (!packageId) return "-";
|
|
292
|
+
const labels = byPackage.get(packageId);
|
|
293
|
+
if (!labels) return shortId(packageId, 6);
|
|
294
|
+
return labels[languageCode] || labels.en || labels.id || Object.values(labels).find(Boolean) || shortId(packageId, 6);
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function createEmptyDashboardData() {
|
|
298
|
+
return {
|
|
299
|
+
stats: {
|
|
300
|
+
bookingsConfirm: 0,
|
|
301
|
+
bookingsPending: 0,
|
|
302
|
+
revenueConfirm: 0,
|
|
303
|
+
packages: 0,
|
|
304
|
+
sections: 0,
|
|
305
|
+
avgRevenue: 0,
|
|
306
|
+
conversionRate: 0
|
|
307
|
+
},
|
|
308
|
+
charts: {
|
|
309
|
+
dailyTrends: [],
|
|
310
|
+
statusDistribution: [],
|
|
311
|
+
audienceDistribution: [],
|
|
312
|
+
topPackages: []
|
|
313
|
+
},
|
|
314
|
+
table: {
|
|
315
|
+
recentBookings: []
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function adaptCidikaDashboardData({
|
|
320
|
+
raw,
|
|
321
|
+
filters,
|
|
322
|
+
range,
|
|
323
|
+
dateLocale,
|
|
324
|
+
languageCode,
|
|
325
|
+
labels
|
|
326
|
+
}) {
|
|
327
|
+
var _a, _b;
|
|
328
|
+
if (!raw) return createEmptyDashboardData();
|
|
329
|
+
const dailyBuckets = buildDayBuckets(range.daysWindow, range.fromISO, dateLocale);
|
|
330
|
+
const dayLookup = new Map(
|
|
331
|
+
dailyBuckets.map((bucket) => [bucket.dateKey, bucket])
|
|
332
|
+
);
|
|
333
|
+
const statusMap = /* @__PURE__ */ new Map();
|
|
334
|
+
const audienceMap = /* @__PURE__ */ new Map();
|
|
335
|
+
const packageCountMap = /* @__PURE__ */ new Map();
|
|
336
|
+
const packageRevenueMap = /* @__PURE__ */ new Map();
|
|
337
|
+
const getPackageTitle = createPackageTitleResolver(
|
|
338
|
+
raw.packageLocales || [],
|
|
339
|
+
languageCode
|
|
340
|
+
);
|
|
341
|
+
let bookingsConfirm = 0;
|
|
342
|
+
let bookingsPending = 0;
|
|
343
|
+
let revenueConfirm = 0;
|
|
344
|
+
(raw.bookings || []).forEach((row) => {
|
|
345
|
+
const dayKey = String(row.created_at || "").slice(0, 10);
|
|
346
|
+
const status = String(row.status || "pending").toLowerCase();
|
|
347
|
+
const total = toNumber(row.total_idr);
|
|
348
|
+
const audience = row.audience || "unknown";
|
|
349
|
+
statusMap.set(status, (statusMap.get(status) || 0) + 1);
|
|
350
|
+
audienceMap.set(audience, (audienceMap.get(audience) || 0) + 1);
|
|
351
|
+
const bucket = dayLookup.get(dayKey);
|
|
352
|
+
if (bucket) {
|
|
353
|
+
if (status === "confirmed") {
|
|
354
|
+
bucket.count += 1;
|
|
355
|
+
bucket.revenue += total;
|
|
356
|
+
}
|
|
357
|
+
if (status === "pending") {
|
|
358
|
+
bucket.pendingCount += 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (status === "confirmed") {
|
|
362
|
+
bookingsConfirm += 1;
|
|
363
|
+
revenueConfirm += total;
|
|
364
|
+
packageCountMap.set(
|
|
365
|
+
row.package_id,
|
|
366
|
+
(packageCountMap.get(row.package_id) || 0) + 1
|
|
367
|
+
);
|
|
368
|
+
packageRevenueMap.set(
|
|
369
|
+
row.package_id,
|
|
370
|
+
(packageRevenueMap.get(row.package_id) || 0) + total
|
|
371
|
+
);
|
|
372
|
+
} else if (status === "pending") {
|
|
373
|
+
bookingsPending += 1;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
const statusDistribution = sortMapEntries(statusMap, "desc").map(
|
|
377
|
+
([status, count]) => ({
|
|
378
|
+
status,
|
|
379
|
+
label: labels.formatStatusLabel(status),
|
|
380
|
+
count
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
const audienceDistribution = sortMapEntries(audienceMap, "desc").map(
|
|
384
|
+
([audience, count]) => ({
|
|
385
|
+
audience,
|
|
386
|
+
label: labels.formatAudienceLabel(audience),
|
|
387
|
+
count
|
|
388
|
+
})
|
|
389
|
+
);
|
|
390
|
+
const metricMap = filters.sortPkgBy === "revenue" ? packageRevenueMap : packageCountMap;
|
|
391
|
+
const topPackages = sortMapEntries(metricMap, filters.sortPkgDir).slice(0, 5).map(([packageId, value]) => ({
|
|
392
|
+
packageId,
|
|
393
|
+
name: getPackageTitle(packageId),
|
|
394
|
+
value: toNumber(value)
|
|
395
|
+
}));
|
|
396
|
+
const recentBookings = (raw.recent || []).map((row) => {
|
|
397
|
+
const status = String(row.status || "pending").toLowerCase();
|
|
398
|
+
return {
|
|
399
|
+
id: row.id,
|
|
400
|
+
createdAt: row.created_at,
|
|
401
|
+
customerName: row.customer_name || "-",
|
|
402
|
+
packageName: getPackageTitle(row.package_id),
|
|
403
|
+
audienceLabel: labels.formatAudienceLabel(row.audience),
|
|
404
|
+
totalIDR: toNumber(row.total_idr),
|
|
405
|
+
status,
|
|
406
|
+
statusLabel: labels.formatStatusLabel(status)
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
const avgRevenue = bookingsConfirm > 0 ? Math.round(revenueConfirm / bookingsConfirm) : 0;
|
|
410
|
+
const conversionRate = bookingsConfirm + bookingsPending > 0 ? Math.round(bookingsConfirm / (bookingsConfirm + bookingsPending) * 100) : 0;
|
|
411
|
+
return {
|
|
412
|
+
stats: {
|
|
413
|
+
bookingsConfirm,
|
|
414
|
+
bookingsPending,
|
|
415
|
+
revenueConfirm,
|
|
416
|
+
packages: toNumber((_a = raw.staticCounts) == null ? void 0 : _a.packages),
|
|
417
|
+
sections: toNumber((_b = raw.staticCounts) == null ? void 0 : _b.sections),
|
|
418
|
+
avgRevenue,
|
|
419
|
+
conversionRate
|
|
420
|
+
},
|
|
421
|
+
charts: {
|
|
422
|
+
dailyTrends: dailyBuckets,
|
|
423
|
+
statusDistribution,
|
|
424
|
+
audienceDistribution,
|
|
425
|
+
topPackages
|
|
426
|
+
},
|
|
427
|
+
table: {
|
|
428
|
+
recentBookings
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/data-adapter/dummyUmkmDashboardAdapter.js
|
|
434
|
+
function createEmptyDummyUmkmData() {
|
|
435
|
+
return {
|
|
436
|
+
stats: {
|
|
437
|
+
bookingsConfirm: 0,
|
|
438
|
+
revenueConfirm: 0,
|
|
439
|
+
totalProducts: 0,
|
|
440
|
+
totalCustomers: 0
|
|
441
|
+
},
|
|
442
|
+
charts: {
|
|
443
|
+
dailyTrends: [],
|
|
444
|
+
statusDistribution: [],
|
|
445
|
+
audienceDistribution: [],
|
|
446
|
+
topPackages: []
|
|
447
|
+
},
|
|
448
|
+
table: {
|
|
449
|
+
recentBookings: []
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function adaptDummyUmkmData({ raw, filters, range, dateLocale, labels }) {
|
|
454
|
+
var _a, _b;
|
|
455
|
+
if (!raw) return createEmptyDummyUmkmData();
|
|
456
|
+
const orders = raw.bookings || [];
|
|
457
|
+
const recent = raw.recent || [];
|
|
458
|
+
const dailyBuckets = buildDayBuckets(range.daysWindow, range.fromISO, dateLocale);
|
|
459
|
+
const dayLookup = new Map(dailyBuckets.map((b) => [b.dateKey, b]));
|
|
460
|
+
const statusMap = /* @__PURE__ */ new Map();
|
|
461
|
+
let totalOrders = 0;
|
|
462
|
+
let totalRevenue = 0;
|
|
463
|
+
orders.forEach((row) => {
|
|
464
|
+
const dayKey = String(row.created_at || "").slice(0, 10);
|
|
465
|
+
const status = String(row.status || "pending").toLowerCase();
|
|
466
|
+
const amount = toNumber(row.total_amount);
|
|
467
|
+
statusMap.set(status, (statusMap.get(status) || 0) + 1);
|
|
468
|
+
const bucket = dayLookup.get(dayKey);
|
|
469
|
+
if (bucket) {
|
|
470
|
+
if (status === "confirmed") {
|
|
471
|
+
bucket.count += 1;
|
|
472
|
+
bucket.revenue += amount;
|
|
473
|
+
}
|
|
474
|
+
if (status === "pending") {
|
|
475
|
+
bucket.pendingCount += 1;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (status === "confirmed") {
|
|
479
|
+
totalOrders += 1;
|
|
480
|
+
totalRevenue += amount;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
const statusDistribution = Array.from(statusMap.entries()).sort((a, b) => b[1] - a[1]).map(([status, count]) => ({
|
|
484
|
+
status,
|
|
485
|
+
label: (labels == null ? void 0 : labels.formatStatusLabel) ? labels.formatStatusLabel(status) : status,
|
|
486
|
+
count
|
|
487
|
+
}));
|
|
488
|
+
const recentBookings = recent.map((row) => {
|
|
489
|
+
const status = String(row.status || "pending").toLowerCase();
|
|
490
|
+
return {
|
|
491
|
+
id: row.id,
|
|
492
|
+
createdAt: row.created_at,
|
|
493
|
+
customerName: row.customer_name || "-",
|
|
494
|
+
packageName: row.product_name || row.product_id || "-",
|
|
495
|
+
totalIDR: toNumber(row.total_amount),
|
|
496
|
+
status,
|
|
497
|
+
statusLabel: (labels == null ? void 0 : labels.formatStatusLabel) ? labels.formatStatusLabel(status) : status
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
return {
|
|
501
|
+
stats: {
|
|
502
|
+
bookingsConfirm: totalOrders,
|
|
503
|
+
revenueConfirm: totalRevenue,
|
|
504
|
+
totalProducts: toNumber((_a = raw.staticCounts) == null ? void 0 : _a.packages),
|
|
505
|
+
totalCustomers: toNumber((_b = raw.staticCounts) == null ? void 0 : _b.sections)
|
|
506
|
+
},
|
|
507
|
+
charts: {
|
|
508
|
+
dailyTrends: dailyBuckets,
|
|
509
|
+
statusDistribution,
|
|
510
|
+
audienceDistribution: [],
|
|
511
|
+
topPackages: []
|
|
512
|
+
},
|
|
513
|
+
table: {
|
|
514
|
+
recentBookings
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/data-adapter/tokoSepatuDashboardAdapter.js
|
|
520
|
+
function createEmptyTokoSepatuData() {
|
|
521
|
+
return {
|
|
522
|
+
stats: {
|
|
523
|
+
bookingsConfirm: 0,
|
|
524
|
+
bookingsPending: 0,
|
|
525
|
+
revenueConfirm: 0,
|
|
526
|
+
packages: 0,
|
|
527
|
+
sections: 0,
|
|
528
|
+
avgRevenue: 0,
|
|
529
|
+
conversionRate: 0
|
|
530
|
+
},
|
|
531
|
+
charts: {
|
|
532
|
+
dailyTrends: [],
|
|
533
|
+
statusDistribution: [],
|
|
534
|
+
audienceDistribution: [],
|
|
535
|
+
topPackages: []
|
|
536
|
+
},
|
|
537
|
+
table: {
|
|
538
|
+
recentBookings: []
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function adaptTokoSepatuData({ raw, filters, range, dateLocale, labels }) {
|
|
543
|
+
var _a, _b;
|
|
544
|
+
if (!raw) return createEmptyTokoSepatuData();
|
|
545
|
+
const orders = raw.bookings || [];
|
|
546
|
+
const recent = raw.recent || [];
|
|
547
|
+
const dailyBuckets = buildDayBuckets(range.daysWindow, range.fromISO, dateLocale);
|
|
548
|
+
const dayLookup = new Map(dailyBuckets.map((b) => [b.dateKey, b]));
|
|
549
|
+
const statusMap = /* @__PURE__ */ new Map();
|
|
550
|
+
const productCountMap = /* @__PURE__ */ new Map();
|
|
551
|
+
const productRevenueMap = /* @__PURE__ */ new Map();
|
|
552
|
+
let ordersConfirm = 0;
|
|
553
|
+
let ordersPending = 0;
|
|
554
|
+
let revenueConfirm = 0;
|
|
555
|
+
const productNameMap = /* @__PURE__ */ new Map();
|
|
556
|
+
(raw.products || []).forEach((p) => {
|
|
557
|
+
productNameMap.set(p.id, p.name);
|
|
558
|
+
});
|
|
559
|
+
orders.forEach((row) => {
|
|
560
|
+
const dayKey = String(row.created_at || "").slice(0, 10);
|
|
561
|
+
const status = String(row.status || "pending").toLowerCase();
|
|
562
|
+
const total = toNumber(row.total_amount);
|
|
563
|
+
statusMap.set(status, (statusMap.get(status) || 0) + 1);
|
|
564
|
+
const bucket = dayLookup.get(dayKey);
|
|
565
|
+
if (bucket) {
|
|
566
|
+
if (status === "confirmed") {
|
|
567
|
+
bucket.count += 1;
|
|
568
|
+
bucket.revenue += total;
|
|
569
|
+
}
|
|
570
|
+
if (status === "pending") {
|
|
571
|
+
bucket.pendingCount += 1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (status === "confirmed") {
|
|
575
|
+
ordersConfirm += 1;
|
|
576
|
+
revenueConfirm += total;
|
|
577
|
+
const items = row.order_items || [];
|
|
578
|
+
items.forEach((item) => {
|
|
579
|
+
const pid = item.product_id;
|
|
580
|
+
productCountMap.set(pid, (productCountMap.get(pid) || 0) + toNumber(item.qty));
|
|
581
|
+
productRevenueMap.set(pid, (productRevenueMap.get(pid) || 0) + toNumber(item.subtotal));
|
|
582
|
+
});
|
|
583
|
+
} else if (status === "pending") {
|
|
584
|
+
ordersPending += 1;
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
const statusDistribution = sortMapEntries(statusMap, "desc").map(
|
|
588
|
+
([status, count]) => ({
|
|
589
|
+
status,
|
|
590
|
+
label: (labels == null ? void 0 : labels.formatStatusLabel) ? labels.formatStatusLabel(status) : status,
|
|
591
|
+
count
|
|
592
|
+
})
|
|
593
|
+
);
|
|
594
|
+
const metricMap = filters.sortPkgBy === "revenue" ? productRevenueMap : productCountMap;
|
|
595
|
+
const topPackages = sortMapEntries(metricMap, filters.sortPkgDir || "desc").slice(0, 5).map(([productId, value]) => ({
|
|
596
|
+
packageId: productId,
|
|
597
|
+
name: productNameMap.get(productId) || shortId(productId, 6),
|
|
598
|
+
value: toNumber(value)
|
|
599
|
+
}));
|
|
600
|
+
const recentBookings = recent.map((row) => {
|
|
601
|
+
const status = String(row.status || "pending").toLowerCase();
|
|
602
|
+
const productName = row.product_name || (row.order_items && row.order_items[0] ? productNameMap.get(row.order_items[0].product_id) : null) || "-";
|
|
603
|
+
return {
|
|
604
|
+
id: row.id,
|
|
605
|
+
createdAt: row.created_at,
|
|
606
|
+
customerName: row.customer_name || "-",
|
|
607
|
+
packageName: productName,
|
|
608
|
+
totalIDR: toNumber(row.total_amount),
|
|
609
|
+
status,
|
|
610
|
+
statusLabel: (labels == null ? void 0 : labels.formatStatusLabel) ? labels.formatStatusLabel(status) : status
|
|
611
|
+
};
|
|
612
|
+
});
|
|
613
|
+
const avgRevenue = ordersConfirm > 0 ? Math.round(revenueConfirm / ordersConfirm) : 0;
|
|
614
|
+
const conversionRate = ordersConfirm + ordersPending > 0 ? Math.round(ordersConfirm / (ordersConfirm + ordersPending) * 100) : 0;
|
|
615
|
+
return {
|
|
616
|
+
stats: {
|
|
617
|
+
bookingsConfirm: ordersConfirm,
|
|
618
|
+
bookingsPending: ordersPending,
|
|
619
|
+
revenueConfirm,
|
|
620
|
+
packages: toNumber((_a = raw.staticCounts) == null ? void 0 : _a.products),
|
|
621
|
+
sections: toNumber((_b = raw.staticCounts) == null ? void 0 : _b.customers),
|
|
622
|
+
avgRevenue,
|
|
623
|
+
conversionRate
|
|
624
|
+
},
|
|
625
|
+
charts: {
|
|
626
|
+
dailyTrends: dailyBuckets,
|
|
627
|
+
statusDistribution,
|
|
628
|
+
audienceDistribution: [],
|
|
629
|
+
topPackages
|
|
630
|
+
},
|
|
631
|
+
table: {
|
|
632
|
+
recentBookings
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/data-source/cidikaSupabaseSource.js
|
|
638
|
+
function ensureNoError(response, message) {
|
|
639
|
+
if (response == null ? void 0 : response.error) {
|
|
640
|
+
const err = new Error(message);
|
|
641
|
+
err.cause = response.error;
|
|
642
|
+
throw err;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function createCidikaSupabaseSource(supabase) {
|
|
646
|
+
return {
|
|
647
|
+
async fetchDashboardSnapshot({
|
|
648
|
+
fromISO,
|
|
649
|
+
toISO,
|
|
650
|
+
audience,
|
|
651
|
+
statusScope,
|
|
652
|
+
languageCode
|
|
653
|
+
}) {
|
|
654
|
+
const bookingsQuery = supabase.from("bookings").select(
|
|
655
|
+
"id, created_at, total_idr, status, package_id, audience, customer_name"
|
|
656
|
+
).gte("created_at", fromISO).lte("created_at", toISO).order("created_at", { ascending: true });
|
|
657
|
+
if (audience) bookingsQuery.eq("audience", audience);
|
|
658
|
+
const recentQuery = supabase.from("bookings").select(
|
|
659
|
+
"id, created_at, customer_name, total_idr, status, package_id, audience"
|
|
660
|
+
).gte("created_at", fromISO).lte("created_at", toISO).order("created_at", { ascending: false }).limit(10);
|
|
661
|
+
if (audience) recentQuery.eq("audience", audience);
|
|
662
|
+
if (statusScope && statusScope !== "all") {
|
|
663
|
+
recentQuery.eq("status", statusScope);
|
|
664
|
+
}
|
|
665
|
+
const [bookingsRes, recentRes, packagesRes, sectionsRes] = await Promise.all(
|
|
666
|
+
[
|
|
667
|
+
bookingsQuery,
|
|
668
|
+
recentQuery,
|
|
669
|
+
supabase.from("packages").select("*", { count: "exact", head: true }),
|
|
670
|
+
supabase.from("page_sections").select("*", { count: "exact", head: true })
|
|
671
|
+
]
|
|
672
|
+
);
|
|
673
|
+
ensureNoError(bookingsRes, "Failed to load booking rows.");
|
|
674
|
+
ensureNoError(recentRes, "Failed to load recent rows.");
|
|
675
|
+
const bookings = bookingsRes.data || [];
|
|
676
|
+
const recent = recentRes.data || [];
|
|
677
|
+
const packageIds = Array.from(
|
|
678
|
+
new Set(
|
|
679
|
+
[...bookings, ...recent].map((row) => row.package_id).filter((value) => Boolean(value))
|
|
680
|
+
)
|
|
681
|
+
);
|
|
682
|
+
let packageLocales = [];
|
|
683
|
+
if (packageIds.length > 0) {
|
|
684
|
+
const localeRes = await supabase.from("package_locales").select("package_id,title,lang").in("package_id", packageIds).in("lang", [languageCode, "en", "id"]);
|
|
685
|
+
if (!localeRes.error) {
|
|
686
|
+
packageLocales = localeRes.data || [];
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
bookings,
|
|
691
|
+
recent,
|
|
692
|
+
packageLocales,
|
|
693
|
+
staticCounts: {
|
|
694
|
+
packages: packagesRes.error ? 0 : packagesRes.count || 0,
|
|
695
|
+
sections: sectionsRes.error ? 0 : sectionsRes.count || 0
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
},
|
|
699
|
+
subscribeLiveUpdate(onEvent) {
|
|
700
|
+
const channel = supabase.channel("reusable-dashboard-live").on(
|
|
701
|
+
"postgres_changes",
|
|
702
|
+
{ event: "*", schema: "public", table: "bookings" },
|
|
703
|
+
onEvent
|
|
704
|
+
).on(
|
|
705
|
+
"postgres_changes",
|
|
706
|
+
{ event: "*", schema: "public", table: "packages" },
|
|
707
|
+
onEvent
|
|
708
|
+
).on(
|
|
709
|
+
"postgres_changes",
|
|
710
|
+
{ event: "*", schema: "public", table: "page_sections" },
|
|
711
|
+
onEvent
|
|
712
|
+
).on(
|
|
713
|
+
"postgres_changes",
|
|
714
|
+
{ event: "*", schema: "public", table: "package_locales" },
|
|
715
|
+
onEvent
|
|
716
|
+
).subscribe();
|
|
717
|
+
return () => {
|
|
718
|
+
supabase.removeChannel(channel);
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/data-source/tokoSepatuSupabaseSource.js
|
|
725
|
+
function ensureNoError2(response, message) {
|
|
726
|
+
if (response == null ? void 0 : response.error) {
|
|
727
|
+
const err = new Error(message);
|
|
728
|
+
err.cause = response.error;
|
|
729
|
+
throw err;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function createTokoSepatuSupabaseSource(supabase) {
|
|
733
|
+
return {
|
|
734
|
+
/**
|
|
735
|
+
* Mengambil snapshot data dashboard dari Supabase.
|
|
736
|
+
*/
|
|
737
|
+
async fetchDashboardSnapshot({
|
|
738
|
+
fromISO,
|
|
739
|
+
toISO,
|
|
740
|
+
audience,
|
|
741
|
+
statusScope
|
|
742
|
+
}) {
|
|
743
|
+
const ordersQuery = supabase.from("orders").select(
|
|
744
|
+
"id, created_at, total_amount, status, customer_id, order_items(product_id, qty, price_idr, subtotal)"
|
|
745
|
+
).gte("created_at", fromISO).lte("created_at", toISO).order("created_at", { ascending: true });
|
|
746
|
+
const recentQuery = supabase.from("orders").select(
|
|
747
|
+
"id, created_at, total_amount, status, customers(name), order_items(product_id, qty, products(name))"
|
|
748
|
+
).gte("created_at", fromISO).lte("created_at", toISO).order("created_at", { ascending: false }).limit(10);
|
|
749
|
+
if (statusScope && statusScope !== "all") {
|
|
750
|
+
recentQuery.eq("status", statusScope);
|
|
751
|
+
}
|
|
752
|
+
const [ordersRes, recentRes, productsRes, customersRes] = await Promise.all([
|
|
753
|
+
ordersQuery,
|
|
754
|
+
recentQuery,
|
|
755
|
+
supabase.from("products").select("id, name, brand, category, price_idr"),
|
|
756
|
+
supabase.from("customers").select("*", { count: "exact", head: true })
|
|
757
|
+
]);
|
|
758
|
+
ensureNoError2(ordersRes, "Failed to load orders.");
|
|
759
|
+
ensureNoError2(recentRes, "Failed to load recent orders.");
|
|
760
|
+
const orders = ordersRes.data || [];
|
|
761
|
+
const recentRaw = recentRes.data || [];
|
|
762
|
+
const products = productsRes.data || [];
|
|
763
|
+
const recent = recentRaw.map((row) => {
|
|
764
|
+
var _a, _b, _c, _d;
|
|
765
|
+
return {
|
|
766
|
+
id: row.id,
|
|
767
|
+
created_at: row.created_at,
|
|
768
|
+
total_amount: row.total_amount,
|
|
769
|
+
status: row.status,
|
|
770
|
+
customer_name: ((_a = row.customers) == null ? void 0 : _a.name) || "-",
|
|
771
|
+
product_name: ((_d = (_c = (_b = row.order_items) == null ? void 0 : _b[0]) == null ? void 0 : _c.products) == null ? void 0 : _d.name) || "-",
|
|
772
|
+
order_items: row.order_items
|
|
773
|
+
};
|
|
774
|
+
});
|
|
775
|
+
return {
|
|
776
|
+
bookings: orders,
|
|
777
|
+
recent,
|
|
778
|
+
products,
|
|
779
|
+
packageLocales: [],
|
|
780
|
+
staticCounts: {
|
|
781
|
+
products: products.length,
|
|
782
|
+
customers: customersRes.error ? 0 : customersRes.count || 0
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
},
|
|
786
|
+
/**
|
|
787
|
+
* Subscribe ke perubahan realtime pada tabel orders dan products.
|
|
788
|
+
*/
|
|
789
|
+
subscribeLiveUpdate(onEvent) {
|
|
790
|
+
const channel = supabase.channel("toko-sepatu-dashboard-live").on(
|
|
791
|
+
"postgres_changes",
|
|
792
|
+
{ event: "*", schema: "public", table: "orders" },
|
|
793
|
+
onEvent
|
|
794
|
+
).on(
|
|
795
|
+
"postgres_changes",
|
|
796
|
+
{ event: "*", schema: "public", table: "products" },
|
|
797
|
+
onEvent
|
|
798
|
+
).on(
|
|
799
|
+
"postgres_changes",
|
|
800
|
+
{ event: "*", schema: "public", table: "order_items" },
|
|
801
|
+
onEvent
|
|
802
|
+
).subscribe();
|
|
803
|
+
return () => {
|
|
804
|
+
supabase.removeChannel(channel);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// src/hooks/useReusableDashboard.js
|
|
811
|
+
import { useCallback, useEffect as useEffect2, useMemo, useState as useState2 } from "react";
|
|
812
|
+
|
|
813
|
+
// src/utils/dateRange.js
|
|
814
|
+
function startOfDayISO(date) {
|
|
815
|
+
const value = new Date(date);
|
|
816
|
+
value.setHours(0, 0, 0, 0);
|
|
817
|
+
return value.toISOString();
|
|
818
|
+
}
|
|
819
|
+
function endOfDayISO(date) {
|
|
820
|
+
const value = new Date(date);
|
|
821
|
+
value.setHours(23, 59, 59, 999);
|
|
822
|
+
return value.toISOString();
|
|
823
|
+
}
|
|
824
|
+
function formatYYYYMMDD(date) {
|
|
825
|
+
const value = new Date(date);
|
|
826
|
+
const year = value.getFullYear();
|
|
827
|
+
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
828
|
+
const day = String(value.getDate()).padStart(2, "0");
|
|
829
|
+
return `${year}-${month}-${day}`;
|
|
830
|
+
}
|
|
831
|
+
function createDefaultFilters(base = {}) {
|
|
832
|
+
const fromDate = /* @__PURE__ */ new Date();
|
|
833
|
+
fromDate.setDate(fromDate.getDate() - 29);
|
|
834
|
+
return {
|
|
835
|
+
statusScope: "confirmed",
|
|
836
|
+
includePendingOverlay: false,
|
|
837
|
+
audience: "",
|
|
838
|
+
daysPreset: 30,
|
|
839
|
+
dateFrom: formatYYYYMMDD(fromDate),
|
|
840
|
+
dateTo: formatYYYYMMDD(/* @__PURE__ */ new Date()),
|
|
841
|
+
sortPkgBy: "bookings",
|
|
842
|
+
sortPkgDir: "desc",
|
|
843
|
+
...base
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
var MIN_YEAR = 2e3;
|
|
847
|
+
var MAX_DAYS_WINDOW = 365 * 5;
|
|
848
|
+
function isReasonableDate(date) {
|
|
849
|
+
if (!date || Number.isNaN(date.getTime())) return false;
|
|
850
|
+
const year = date.getFullYear();
|
|
851
|
+
return year >= MIN_YEAR && year <= (/* @__PURE__ */ new Date()).getFullYear() + 1;
|
|
852
|
+
}
|
|
853
|
+
function resolveDateRange({ daysPreset, dateFrom, dateTo }) {
|
|
854
|
+
if (Number(daysPreset) > 0) {
|
|
855
|
+
const fromDate = /* @__PURE__ */ new Date();
|
|
856
|
+
fromDate.setDate(fromDate.getDate() - (Number(daysPreset) - 1));
|
|
857
|
+
const toDate = /* @__PURE__ */ new Date();
|
|
858
|
+
return {
|
|
859
|
+
fromISO: startOfDayISO(fromDate),
|
|
860
|
+
toISO: endOfDayISO(toDate),
|
|
861
|
+
daysWindow: Number(daysPreset)
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const fallbackFrom = new Date(Date.now() - 29 * 24 * 60 * 60 * 1e3);
|
|
865
|
+
const fallbackTo = /* @__PURE__ */ new Date();
|
|
866
|
+
let from = dateFrom ? new Date(dateFrom) : fallbackFrom;
|
|
867
|
+
let to = dateTo ? new Date(dateTo) : fallbackTo;
|
|
868
|
+
if (!isReasonableDate(from)) from = fallbackFrom;
|
|
869
|
+
if (!isReasonableDate(to)) to = fallbackTo;
|
|
870
|
+
if (from.getTime() > to.getTime()) {
|
|
871
|
+
const swap = from;
|
|
872
|
+
from = to;
|
|
873
|
+
to = swap;
|
|
874
|
+
}
|
|
875
|
+
const fromISO = startOfDayISO(from);
|
|
876
|
+
const toISO = endOfDayISO(to);
|
|
877
|
+
const diffMs = new Date(toISO).getTime() - new Date(fromISO).getTime();
|
|
878
|
+
const daysWindow = Math.min(
|
|
879
|
+
MAX_DAYS_WINDOW,
|
|
880
|
+
Math.max(1, Math.round(diffMs / 864e5) + 1)
|
|
881
|
+
);
|
|
882
|
+
return { fromISO, toISO, daysWindow };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/hooks/useRealtimeUpdate.js
|
|
886
|
+
import { useEffect, useState } from "react";
|
|
887
|
+
function useRealtimeUpdate({ dataSource, onUpdate, debounceMs = 350 }) {
|
|
888
|
+
const [liveUpdateEnabled, setLiveUpdateEnabled] = useState(false);
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (!(dataSource == null ? void 0 : dataSource.subscribeLiveUpdate)) return void 0;
|
|
891
|
+
let debounceId;
|
|
892
|
+
setLiveUpdateEnabled(true);
|
|
893
|
+
const unsubscribe = dataSource.subscribeLiveUpdate(() => {
|
|
894
|
+
clearTimeout(debounceId);
|
|
895
|
+
debounceId = setTimeout(() => {
|
|
896
|
+
onUpdate == null ? void 0 : onUpdate();
|
|
897
|
+
}, debounceMs);
|
|
898
|
+
});
|
|
899
|
+
return () => {
|
|
900
|
+
clearTimeout(debounceId);
|
|
901
|
+
setLiveUpdateEnabled(false);
|
|
902
|
+
unsubscribe == null ? void 0 : unsubscribe();
|
|
903
|
+
};
|
|
904
|
+
}, [dataSource, debounceMs]);
|
|
905
|
+
return { liveUpdateEnabled };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/hooks/useReusableDashboard.js
|
|
909
|
+
function useReusableDashboard({
|
|
910
|
+
config,
|
|
911
|
+
dataSource,
|
|
912
|
+
adapter,
|
|
913
|
+
createEmptyState,
|
|
914
|
+
languageCode,
|
|
915
|
+
dateLocale,
|
|
916
|
+
labels
|
|
917
|
+
}) {
|
|
918
|
+
const [filters, setFilters] = useState2(
|
|
919
|
+
() => createDefaultFilters(config == null ? void 0 : config.defaultFilters)
|
|
920
|
+
);
|
|
921
|
+
const [loading, setLoading] = useState2(true);
|
|
922
|
+
const [error, setError] = useState2("");
|
|
923
|
+
const [lastUpdatedAt, setLastUpdatedAt] = useState2(null);
|
|
924
|
+
const [data, setData] = useState2(() => createEmptyState());
|
|
925
|
+
const range = useMemo(
|
|
926
|
+
() => resolveDateRange({
|
|
927
|
+
daysPreset: filters.daysPreset,
|
|
928
|
+
dateFrom: filters.dateFrom,
|
|
929
|
+
dateTo: filters.dateTo
|
|
930
|
+
}),
|
|
931
|
+
[filters.daysPreset, filters.dateFrom, filters.dateTo]
|
|
932
|
+
);
|
|
933
|
+
const refresh = useCallback(
|
|
934
|
+
async ({ silent = false } = {}) => {
|
|
935
|
+
if (!(dataSource == null ? void 0 : dataSource.fetchDashboardSnapshot)) return;
|
|
936
|
+
if (!silent) {
|
|
937
|
+
setLoading(true);
|
|
938
|
+
setError("");
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
const raw = await dataSource.fetchDashboardSnapshot({
|
|
942
|
+
fromISO: range.fromISO,
|
|
943
|
+
toISO: range.toISO,
|
|
944
|
+
audience: filters.audience,
|
|
945
|
+
statusScope: filters.statusScope,
|
|
946
|
+
languageCode
|
|
947
|
+
});
|
|
948
|
+
const adapted = adapter({
|
|
949
|
+
raw,
|
|
950
|
+
filters,
|
|
951
|
+
range,
|
|
952
|
+
dateLocale,
|
|
953
|
+
languageCode,
|
|
954
|
+
labels
|
|
955
|
+
});
|
|
956
|
+
setData(adapted);
|
|
957
|
+
setLastUpdatedAt(/* @__PURE__ */ new Date());
|
|
958
|
+
if (!silent) setError("");
|
|
959
|
+
} catch (loadError) {
|
|
960
|
+
console.error(loadError);
|
|
961
|
+
if (!silent) {
|
|
962
|
+
setError(labels.loadFailed);
|
|
963
|
+
setData(createEmptyState());
|
|
964
|
+
}
|
|
965
|
+
} finally {
|
|
966
|
+
if (!silent) setLoading(false);
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
[
|
|
970
|
+
dataSource,
|
|
971
|
+
range,
|
|
972
|
+
filters,
|
|
973
|
+
languageCode,
|
|
974
|
+
adapter,
|
|
975
|
+
dateLocale,
|
|
976
|
+
labels,
|
|
977
|
+
createEmptyState
|
|
978
|
+
]
|
|
979
|
+
);
|
|
980
|
+
useEffect2(() => {
|
|
981
|
+
refresh();
|
|
982
|
+
}, [refresh]);
|
|
983
|
+
const { liveUpdateEnabled } = useRealtimeUpdate({
|
|
984
|
+
dataSource,
|
|
985
|
+
onUpdate: () => refresh({ silent: true })
|
|
986
|
+
});
|
|
987
|
+
const updateFilter = useCallback((field, value) => {
|
|
988
|
+
setFilters((current) => ({
|
|
989
|
+
...current,
|
|
990
|
+
[field]: value
|
|
991
|
+
}));
|
|
992
|
+
}, []);
|
|
993
|
+
const resetFilters = useCallback(() => {
|
|
994
|
+
setFilters(createDefaultFilters(config == null ? void 0 : config.defaultFilters));
|
|
995
|
+
}, [config == null ? void 0 : config.defaultFilters]);
|
|
996
|
+
return {
|
|
997
|
+
filters,
|
|
998
|
+
updateFilter,
|
|
999
|
+
resetFilters,
|
|
1000
|
+
loading,
|
|
1001
|
+
error,
|
|
1002
|
+
data,
|
|
1003
|
+
refresh,
|
|
1004
|
+
range,
|
|
1005
|
+
liveUpdateEnabled,
|
|
1006
|
+
lastUpdatedAt
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/presentation/atoms/Button.jsx
|
|
1011
|
+
import React from "react";
|
|
1012
|
+
import PropTypes from "prop-types";
|
|
1013
|
+
function Button({
|
|
1014
|
+
variant = "primary",
|
|
1015
|
+
size = "md",
|
|
1016
|
+
disabled = false,
|
|
1017
|
+
className = "",
|
|
1018
|
+
onClick,
|
|
1019
|
+
children,
|
|
1020
|
+
...rest
|
|
1021
|
+
}) {
|
|
1022
|
+
const base = "inline-flex items-center justify-center font-medium rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
|
1023
|
+
const variants = {
|
|
1024
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
|
1025
|
+
secondary: "border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 focus:ring-slate-400",
|
|
1026
|
+
ghost: "text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 focus:ring-slate-400"
|
|
1027
|
+
};
|
|
1028
|
+
const sizes = {
|
|
1029
|
+
sm: "px-2.5 py-1 text-xs gap-1",
|
|
1030
|
+
md: "px-3 py-1.5 text-sm gap-1.5",
|
|
1031
|
+
lg: "px-4 py-2 text-base gap-2"
|
|
1032
|
+
};
|
|
1033
|
+
const classes = [base, variants[variant] || variants.primary, sizes[size] || sizes.md, className].filter(Boolean).join(" ");
|
|
1034
|
+
return /* @__PURE__ */ React.createElement(
|
|
1035
|
+
"button",
|
|
1036
|
+
{
|
|
1037
|
+
type: "button",
|
|
1038
|
+
className: classes,
|
|
1039
|
+
disabled,
|
|
1040
|
+
onClick,
|
|
1041
|
+
...rest
|
|
1042
|
+
},
|
|
1043
|
+
children
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
Button.propTypes = {
|
|
1047
|
+
/** Varian visual tombol. */
|
|
1048
|
+
variant: PropTypes.oneOf(["primary", "secondary", "ghost"]),
|
|
1049
|
+
/** Ukuran tombol. */
|
|
1050
|
+
size: PropTypes.oneOf(["sm", "md", "lg"]),
|
|
1051
|
+
/** Apakah tombol non-aktif. */
|
|
1052
|
+
disabled: PropTypes.bool,
|
|
1053
|
+
/** ClassName tambahan dari consumer. */
|
|
1054
|
+
className: PropTypes.string,
|
|
1055
|
+
/** Callback saat tombol diklik. */
|
|
1056
|
+
onClick: PropTypes.func,
|
|
1057
|
+
/** Konten tombol. */
|
|
1058
|
+
children: PropTypes.node.isRequired
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// src/presentation/atoms/Input.jsx
|
|
1062
|
+
import React2 from "react";
|
|
1063
|
+
import PropTypes2 from "prop-types";
|
|
1064
|
+
function Input({
|
|
1065
|
+
type = "text",
|
|
1066
|
+
value,
|
|
1067
|
+
onChange,
|
|
1068
|
+
placeholder,
|
|
1069
|
+
label,
|
|
1070
|
+
disabled = false,
|
|
1071
|
+
className = "",
|
|
1072
|
+
...rest
|
|
1073
|
+
}) {
|
|
1074
|
+
const inputClasses = [
|
|
1075
|
+
"px-3 py-2 rounded-2xl border border-slate-300 dark:border-slate-600",
|
|
1076
|
+
"bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100",
|
|
1077
|
+
"text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
|
1078
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
1079
|
+
className
|
|
1080
|
+
].filter(Boolean).join(" ");
|
|
1081
|
+
return /* @__PURE__ */ React2.createElement("div", { className: "flex flex-col gap-1" }, label ? /* @__PURE__ */ React2.createElement("label", { className: "text-xs font-medium text-slate-500 dark:text-slate-400" }, label) : null, /* @__PURE__ */ React2.createElement(
|
|
1082
|
+
"input",
|
|
1083
|
+
{
|
|
1084
|
+
type,
|
|
1085
|
+
value,
|
|
1086
|
+
onChange,
|
|
1087
|
+
placeholder,
|
|
1088
|
+
disabled,
|
|
1089
|
+
className: inputClasses,
|
|
1090
|
+
...rest
|
|
1091
|
+
}
|
|
1092
|
+
));
|
|
1093
|
+
}
|
|
1094
|
+
Input.propTypes = {
|
|
1095
|
+
/** Tipe input. */
|
|
1096
|
+
type: PropTypes2.string,
|
|
1097
|
+
/** Nilai input. */
|
|
1098
|
+
value: PropTypes2.oneOfType([PropTypes2.string, PropTypes2.number]),
|
|
1099
|
+
/** Callback saat nilai berubah. */
|
|
1100
|
+
onChange: PropTypes2.func,
|
|
1101
|
+
/** Placeholder teks. */
|
|
1102
|
+
placeholder: PropTypes2.string,
|
|
1103
|
+
/** Label di atas input. */
|
|
1104
|
+
label: PropTypes2.string,
|
|
1105
|
+
/** Apakah input non-aktif. */
|
|
1106
|
+
disabled: PropTypes2.bool,
|
|
1107
|
+
/** ClassName tambahan. */
|
|
1108
|
+
className: PropTypes2.string
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
// src/presentation/atoms/Icon.jsx
|
|
1112
|
+
import React3 from "react";
|
|
1113
|
+
import PropTypes3 from "prop-types";
|
|
1114
|
+
import {
|
|
1115
|
+
BarChart3,
|
|
1116
|
+
Calendar,
|
|
1117
|
+
DollarSign,
|
|
1118
|
+
PieChart,
|
|
1119
|
+
RotateCcw,
|
|
1120
|
+
TrendingUp,
|
|
1121
|
+
TrendingDown,
|
|
1122
|
+
Users,
|
|
1123
|
+
Search,
|
|
1124
|
+
ChevronLeft,
|
|
1125
|
+
ChevronRight,
|
|
1126
|
+
ArrowUp,
|
|
1127
|
+
ArrowDown,
|
|
1128
|
+
AlertCircle
|
|
1129
|
+
} from "lucide-react";
|
|
1130
|
+
var ICON_REGISTRY = {
|
|
1131
|
+
BarChart3,
|
|
1132
|
+
Calendar,
|
|
1133
|
+
DollarSign,
|
|
1134
|
+
PieChart,
|
|
1135
|
+
RotateCcw,
|
|
1136
|
+
TrendingUp,
|
|
1137
|
+
TrendingDown,
|
|
1138
|
+
Users,
|
|
1139
|
+
Search,
|
|
1140
|
+
ChevronLeft,
|
|
1141
|
+
ChevronRight,
|
|
1142
|
+
ArrowUp,
|
|
1143
|
+
ArrowDown,
|
|
1144
|
+
AlertCircle
|
|
1145
|
+
};
|
|
1146
|
+
function Icon({ name, size = 20, className = "", ...rest }) {
|
|
1147
|
+
const IconComponent = ICON_REGISTRY[name] || BarChart3;
|
|
1148
|
+
return /* @__PURE__ */ React3.createElement(IconComponent, { size, className, ...rest });
|
|
1149
|
+
}
|
|
1150
|
+
function resolveIcon(name) {
|
|
1151
|
+
return ICON_REGISTRY[name] || BarChart3;
|
|
1152
|
+
}
|
|
1153
|
+
Icon.propTypes = {
|
|
1154
|
+
/** Nama ikon dari registry Lucide React. */
|
|
1155
|
+
name: PropTypes3.string.isRequired,
|
|
1156
|
+
/** Ukuran ikon dalam piksel. */
|
|
1157
|
+
size: PropTypes3.number,
|
|
1158
|
+
/** ClassName tambahan. */
|
|
1159
|
+
className: PropTypes3.string
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// src/presentation/atoms/Typography.jsx
|
|
1163
|
+
import React4 from "react";
|
|
1164
|
+
import PropTypes4 from "prop-types";
|
|
1165
|
+
var VARIANT_MAP = {
|
|
1166
|
+
h1: { tag: "h1", className: "text-2xl sm:text-3xl font-bold" },
|
|
1167
|
+
h2: { tag: "h2", className: "text-xl sm:text-2xl font-bold" },
|
|
1168
|
+
h3: { tag: "h3", className: "text-lg font-semibold" },
|
|
1169
|
+
subheading: { tag: "p", className: "text-sm font-medium text-slate-500 dark:text-slate-400" },
|
|
1170
|
+
body: { tag: "p", className: "text-sm text-slate-700 dark:text-slate-300" },
|
|
1171
|
+
caption: { tag: "span", className: "text-xs text-slate-500 dark:text-slate-400" },
|
|
1172
|
+
metric: { tag: "span", className: "text-3xl font-bold text-slate-900 dark:text-slate-100" }
|
|
1173
|
+
};
|
|
1174
|
+
function Typography({
|
|
1175
|
+
variant = "body",
|
|
1176
|
+
className = "",
|
|
1177
|
+
children,
|
|
1178
|
+
...rest
|
|
1179
|
+
}) {
|
|
1180
|
+
const config = VARIANT_MAP[variant] || VARIANT_MAP.body;
|
|
1181
|
+
const Tag = config.tag;
|
|
1182
|
+
const classes = [config.className, className].filter(Boolean).join(" ");
|
|
1183
|
+
return /* @__PURE__ */ React4.createElement(Tag, { className: classes, ...rest }, children);
|
|
1184
|
+
}
|
|
1185
|
+
Typography.propTypes = {
|
|
1186
|
+
/** Varian tipografi. */
|
|
1187
|
+
variant: PropTypes4.oneOf(["h1", "h2", "h3", "subheading", "body", "caption", "metric"]),
|
|
1188
|
+
/** ClassName tambahan. */
|
|
1189
|
+
className: PropTypes4.string,
|
|
1190
|
+
/** Konten teks. */
|
|
1191
|
+
children: PropTypes4.node.isRequired
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
// src/presentation/atoms/Badge.jsx
|
|
1195
|
+
import React5 from "react";
|
|
1196
|
+
import PropTypes5 from "prop-types";
|
|
1197
|
+
var STATUS_CLASSES = {
|
|
1198
|
+
confirmed: "bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200",
|
|
1199
|
+
pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-200",
|
|
1200
|
+
cancelled: "bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200",
|
|
1201
|
+
default: "bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200",
|
|
1202
|
+
success: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
|
1203
|
+
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
|
|
1204
|
+
};
|
|
1205
|
+
function Badge({ status = "default", className = "", children, ...rest }) {
|
|
1206
|
+
const colorClass = STATUS_CLASSES[status] || STATUS_CLASSES.default;
|
|
1207
|
+
const classes = ["px-2 py-1 rounded-full text-xs font-medium", colorClass, className].filter(Boolean).join(" ");
|
|
1208
|
+
return /* @__PURE__ */ React5.createElement("span", { className: classes, ...rest }, children);
|
|
1209
|
+
}
|
|
1210
|
+
Badge.propTypes = {
|
|
1211
|
+
/** Status yang menentukan warna badge. */
|
|
1212
|
+
status: PropTypes5.string,
|
|
1213
|
+
/** ClassName tambahan. */
|
|
1214
|
+
className: PropTypes5.string,
|
|
1215
|
+
/** Label teks badge. */
|
|
1216
|
+
children: PropTypes5.node.isRequired
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
// src/presentation/atoms/SkeletonLoader.jsx
|
|
1220
|
+
import React6 from "react";
|
|
1221
|
+
import PropTypes6 from "prop-types";
|
|
1222
|
+
function SkeletonLoader({ className = "" }) {
|
|
1223
|
+
return /* @__PURE__ */ React6.createElement(
|
|
1224
|
+
"div",
|
|
1225
|
+
{
|
|
1226
|
+
className: `animate-pulse bg-slate-200 dark:bg-slate-700 rounded-xl ${className}`,
|
|
1227
|
+
role: "status",
|
|
1228
|
+
"aria-label": "Loading..."
|
|
1229
|
+
}
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
SkeletonLoader.propTypes = {
|
|
1233
|
+
/** ClassName tambahan untuk mengatur ukuran skeleton (misal: "h-28", "h-64"). */
|
|
1234
|
+
className: PropTypes6.string
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
// src/presentation/molecules/StatCard.jsx
|
|
1238
|
+
import React7 from "react";
|
|
1239
|
+
import PropTypes7 from "prop-types";
|
|
1240
|
+
function StatCard({
|
|
1241
|
+
label,
|
|
1242
|
+
value,
|
|
1243
|
+
icon = "TrendingUp",
|
|
1244
|
+
format = "number",
|
|
1245
|
+
trend = null,
|
|
1246
|
+
className = ""
|
|
1247
|
+
}) {
|
|
1248
|
+
const formattedValue = renderValue(format, value);
|
|
1249
|
+
return /* @__PURE__ */ React7.createElement("div", { className: `card p-4 flex flex-col justify-between gap-2 ${className}` }, /* @__PURE__ */ React7.createElement("div", { className: "flex items-center justify-between" }, /* @__PURE__ */ React7.createElement("div", { className: "flex items-center gap-2 text-slate-500 dark:text-slate-400" }, /* @__PURE__ */ React7.createElement(Icon, { name: icon, size: 20 }), /* @__PURE__ */ React7.createElement(Typography, { variant: "subheading" }, label)), trend ? /* @__PURE__ */ React7.createElement(
|
|
1250
|
+
Icon,
|
|
1251
|
+
{
|
|
1252
|
+
name: trend === "up" ? "TrendingUp" : "TrendingDown",
|
|
1253
|
+
size: 16,
|
|
1254
|
+
className: trend === "up" ? "text-emerald-500" : "text-red-500"
|
|
1255
|
+
}
|
|
1256
|
+
) : null), /* @__PURE__ */ React7.createElement(Typography, { variant: "metric" }, formattedValue));
|
|
1257
|
+
}
|
|
1258
|
+
function renderValue(format, value) {
|
|
1259
|
+
if (format === "currency") return `Rp ${formatIDR(value)}`;
|
|
1260
|
+
if (format === "percent") return `${Number(value) || 0}%`;
|
|
1261
|
+
return String(Number(value) || 0);
|
|
1262
|
+
}
|
|
1263
|
+
StatCard.propTypes = {
|
|
1264
|
+
/** Label deskriptif metrik. */
|
|
1265
|
+
label: PropTypes7.string.isRequired,
|
|
1266
|
+
/** Nilai metrik. */
|
|
1267
|
+
value: PropTypes7.oneOfType([PropTypes7.number, PropTypes7.string]),
|
|
1268
|
+
/** Nama ikon dari registry Lucide React. */
|
|
1269
|
+
icon: PropTypes7.string,
|
|
1270
|
+
/** Format tampilan nilai. */
|
|
1271
|
+
format: PropTypes7.oneOf(["number", "currency", "percent"]),
|
|
1272
|
+
/** Arah tren opsional. */
|
|
1273
|
+
trend: PropTypes7.oneOf(["up", "down", null]),
|
|
1274
|
+
/** ClassName tambahan. */
|
|
1275
|
+
className: PropTypes7.string
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// src/presentation/molecules/SearchBar.jsx
|
|
1279
|
+
import React8, { useState as useState3, useCallback as useCallback2 } from "react";
|
|
1280
|
+
import PropTypes8 from "prop-types";
|
|
1281
|
+
function SearchBar({
|
|
1282
|
+
value: controlledValue,
|
|
1283
|
+
onChange,
|
|
1284
|
+
onSearch,
|
|
1285
|
+
placeholder = "Search...",
|
|
1286
|
+
className = ""
|
|
1287
|
+
}) {
|
|
1288
|
+
const [internalValue, setInternalValue] = useState3("");
|
|
1289
|
+
const isControlled = controlledValue !== void 0;
|
|
1290
|
+
const currentValue = isControlled ? controlledValue : internalValue;
|
|
1291
|
+
const handleChange = useCallback2(
|
|
1292
|
+
(event) => {
|
|
1293
|
+
const newValue = event.target.value;
|
|
1294
|
+
if (!isControlled) setInternalValue(newValue);
|
|
1295
|
+
if (onChange) onChange(newValue);
|
|
1296
|
+
if (onSearch) onSearch(newValue);
|
|
1297
|
+
},
|
|
1298
|
+
[isControlled, onChange, onSearch]
|
|
1299
|
+
);
|
|
1300
|
+
const handleKeyDown = useCallback2(
|
|
1301
|
+
(event) => {
|
|
1302
|
+
if (event.key === "Enter" && onSearch) {
|
|
1303
|
+
onSearch(currentValue);
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
[currentValue, onSearch]
|
|
1307
|
+
);
|
|
1308
|
+
return /* @__PURE__ */ React8.createElement("div", { className: `relative ${className}` }, /* @__PURE__ */ React8.createElement("div", { className: "absolute inset-y-0 left-3 flex items-center pointer-events-none" }, /* @__PURE__ */ React8.createElement(Icon, { name: "Search", size: 16, className: "text-slate-400" })), /* @__PURE__ */ React8.createElement(
|
|
1309
|
+
Input,
|
|
1310
|
+
{
|
|
1311
|
+
type: "search",
|
|
1312
|
+
value: currentValue,
|
|
1313
|
+
onChange: handleChange,
|
|
1314
|
+
onKeyDown: handleKeyDown,
|
|
1315
|
+
placeholder,
|
|
1316
|
+
className: "pl-9 w-full"
|
|
1317
|
+
}
|
|
1318
|
+
));
|
|
1319
|
+
}
|
|
1320
|
+
SearchBar.propTypes = {
|
|
1321
|
+
/** Nilai pencarian saat ini (controlled). */
|
|
1322
|
+
value: PropTypes8.string,
|
|
1323
|
+
/** Callback saat nilai input berubah. */
|
|
1324
|
+
onChange: PropTypes8.func,
|
|
1325
|
+
/** Callback saat pencarian disubmit. */
|
|
1326
|
+
onSearch: PropTypes8.func,
|
|
1327
|
+
/** Placeholder teks. */
|
|
1328
|
+
placeholder: PropTypes8.string,
|
|
1329
|
+
/** ClassName tambahan. */
|
|
1330
|
+
className: PropTypes8.string
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
// src/presentation/molecules/DateRangeFilter.jsx
|
|
1334
|
+
import React9, { useState as useState4, useCallback as useCallback3 } from "react";
|
|
1335
|
+
import PropTypes9 from "prop-types";
|
|
1336
|
+
var CURRENT_YEAR = (/* @__PURE__ */ new Date()).getFullYear();
|
|
1337
|
+
var MIN_YEAR2 = 2020;
|
|
1338
|
+
var MAX_YEAR = CURRENT_YEAR + 1;
|
|
1339
|
+
var MONTHS = [
|
|
1340
|
+
"Jan",
|
|
1341
|
+
"Feb",
|
|
1342
|
+
"Mar",
|
|
1343
|
+
"Apr",
|
|
1344
|
+
"May",
|
|
1345
|
+
"Jun",
|
|
1346
|
+
"Jul",
|
|
1347
|
+
"Aug",
|
|
1348
|
+
"Sep",
|
|
1349
|
+
"Oct",
|
|
1350
|
+
"Nov",
|
|
1351
|
+
"Dec"
|
|
1352
|
+
];
|
|
1353
|
+
function getYears() {
|
|
1354
|
+
const years = [];
|
|
1355
|
+
for (let y = MAX_YEAR; y >= MIN_YEAR2; y--) {
|
|
1356
|
+
years.push(y);
|
|
1357
|
+
}
|
|
1358
|
+
return years;
|
|
1359
|
+
}
|
|
1360
|
+
function getDaysInMonth(year, month) {
|
|
1361
|
+
return new Date(year, month, 0).getDate();
|
|
1362
|
+
}
|
|
1363
|
+
function parseDate(value) {
|
|
1364
|
+
if (value && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
1365
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
1366
|
+
if (y >= MIN_YEAR2 && y <= MAX_YEAR && m >= 1 && m <= 12) {
|
|
1367
|
+
return { year: y, month: m, day: d };
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const now = /* @__PURE__ */ new Date();
|
|
1371
|
+
return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() };
|
|
1372
|
+
}
|
|
1373
|
+
function formatDate2({ year, month, day }) {
|
|
1374
|
+
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
1375
|
+
}
|
|
1376
|
+
function DatePicker({ value, onChange, disabled = false, label = "" }) {
|
|
1377
|
+
const parsed = parseDate(value);
|
|
1378
|
+
const years = getYears();
|
|
1379
|
+
const maxDay = getDaysInMonth(parsed.year, parsed.month);
|
|
1380
|
+
const handleChange = useCallback3(
|
|
1381
|
+
(field, newVal) => {
|
|
1382
|
+
const updated = { ...parsed, [field]: Number(newVal) };
|
|
1383
|
+
const maxD = getDaysInMonth(updated.year, updated.month);
|
|
1384
|
+
if (updated.day > maxD) updated.day = maxD;
|
|
1385
|
+
onChange(formatDate2(updated));
|
|
1386
|
+
},
|
|
1387
|
+
[parsed, onChange]
|
|
1388
|
+
);
|
|
1389
|
+
const selectClass = "px-2 py-1.5 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed";
|
|
1390
|
+
return /* @__PURE__ */ React9.createElement("div", { className: "flex flex-col gap-1" }, label ? /* @__PURE__ */ React9.createElement(Typography, { variant: "caption", className: "font-medium" }, label) : null, /* @__PURE__ */ React9.createElement("div", { className: "flex items-center gap-1" }, /* @__PURE__ */ React9.createElement(
|
|
1391
|
+
"select",
|
|
1392
|
+
{
|
|
1393
|
+
className: selectClass,
|
|
1394
|
+
value: parsed.day,
|
|
1395
|
+
onChange: (e) => handleChange("day", e.target.value),
|
|
1396
|
+
disabled,
|
|
1397
|
+
"aria-label": `${label} day`
|
|
1398
|
+
},
|
|
1399
|
+
Array.from({ length: maxDay }, (_, i) => i + 1).map((d) => /* @__PURE__ */ React9.createElement("option", { key: d, value: d }, String(d).padStart(2, "0")))
|
|
1400
|
+
), /* @__PURE__ */ React9.createElement(
|
|
1401
|
+
"select",
|
|
1402
|
+
{
|
|
1403
|
+
className: selectClass,
|
|
1404
|
+
value: parsed.month,
|
|
1405
|
+
onChange: (e) => handleChange("month", e.target.value),
|
|
1406
|
+
disabled,
|
|
1407
|
+
"aria-label": `${label} month`
|
|
1408
|
+
},
|
|
1409
|
+
MONTHS.map((name, idx) => /* @__PURE__ */ React9.createElement("option", { key: idx + 1, value: idx + 1 }, name))
|
|
1410
|
+
), /* @__PURE__ */ React9.createElement(
|
|
1411
|
+
"select",
|
|
1412
|
+
{
|
|
1413
|
+
className: selectClass,
|
|
1414
|
+
value: parsed.year,
|
|
1415
|
+
onChange: (e) => handleChange("year", e.target.value),
|
|
1416
|
+
disabled,
|
|
1417
|
+
"aria-label": `${label} year`
|
|
1418
|
+
},
|
|
1419
|
+
years.map((y) => /* @__PURE__ */ React9.createElement("option", { key: y, value: y }, y))
|
|
1420
|
+
)));
|
|
1421
|
+
}
|
|
1422
|
+
DatePicker.propTypes = {
|
|
1423
|
+
value: PropTypes9.string.isRequired,
|
|
1424
|
+
onChange: PropTypes9.func.isRequired,
|
|
1425
|
+
disabled: PropTypes9.bool,
|
|
1426
|
+
label: PropTypes9.string
|
|
1427
|
+
};
|
|
1428
|
+
function DateRangeFilter({
|
|
1429
|
+
dateFrom,
|
|
1430
|
+
dateTo,
|
|
1431
|
+
onRangeChange,
|
|
1432
|
+
disabled = false,
|
|
1433
|
+
labelFrom = "From",
|
|
1434
|
+
labelTo = "To",
|
|
1435
|
+
className = ""
|
|
1436
|
+
}) {
|
|
1437
|
+
const handleFromChange = useCallback3(
|
|
1438
|
+
(newDate) => onRangeChange == null ? void 0 : onRangeChange({ dateFrom: newDate, dateTo }),
|
|
1439
|
+
[dateTo, onRangeChange]
|
|
1440
|
+
);
|
|
1441
|
+
const handleToChange = useCallback3(
|
|
1442
|
+
(newDate) => onRangeChange == null ? void 0 : onRangeChange({ dateFrom, dateTo: newDate }),
|
|
1443
|
+
[dateFrom, onRangeChange]
|
|
1444
|
+
);
|
|
1445
|
+
return /* @__PURE__ */ React9.createElement("div", { className: `flex flex-wrap items-end gap-3 ${className}` }, /* @__PURE__ */ React9.createElement(
|
|
1446
|
+
DatePicker,
|
|
1447
|
+
{
|
|
1448
|
+
value: dateFrom,
|
|
1449
|
+
onChange: handleFromChange,
|
|
1450
|
+
disabled,
|
|
1451
|
+
label: labelFrom
|
|
1452
|
+
}
|
|
1453
|
+
), /* @__PURE__ */ React9.createElement(
|
|
1454
|
+
DatePicker,
|
|
1455
|
+
{
|
|
1456
|
+
value: dateTo,
|
|
1457
|
+
onChange: handleToChange,
|
|
1458
|
+
disabled,
|
|
1459
|
+
label: labelTo
|
|
1460
|
+
}
|
|
1461
|
+
));
|
|
1462
|
+
}
|
|
1463
|
+
DateRangeFilter.propTypes = {
|
|
1464
|
+
dateFrom: PropTypes9.string.isRequired,
|
|
1465
|
+
dateTo: PropTypes9.string.isRequired,
|
|
1466
|
+
onRangeChange: PropTypes9.func.isRequired,
|
|
1467
|
+
disabled: PropTypes9.bool,
|
|
1468
|
+
labelFrom: PropTypes9.string,
|
|
1469
|
+
labelTo: PropTypes9.string,
|
|
1470
|
+
className: PropTypes9.string
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
// src/presentation/molecules/ChartHeader.jsx
|
|
1474
|
+
import React10 from "react";
|
|
1475
|
+
import PropTypes10 from "prop-types";
|
|
1476
|
+
function ChartHeader({
|
|
1477
|
+
title,
|
|
1478
|
+
icon = "BarChart3",
|
|
1479
|
+
actions = null,
|
|
1480
|
+
className = ""
|
|
1481
|
+
}) {
|
|
1482
|
+
return /* @__PURE__ */ React10.createElement("div", { className: `flex items-center justify-between mb-2 ${className}` }, /* @__PURE__ */ React10.createElement(Typography, { variant: "h3", className: "flex items-center gap-2" }, /* @__PURE__ */ React10.createElement(Icon, { name: icon, size: 18 }), title), actions);
|
|
1483
|
+
}
|
|
1484
|
+
ChartHeader.propTypes = {
|
|
1485
|
+
/** Judul grafik. */
|
|
1486
|
+
title: PropTypes10.string.isRequired,
|
|
1487
|
+
/** Nama ikon header. */
|
|
1488
|
+
icon: PropTypes10.string,
|
|
1489
|
+
/** Elemen aksi tambahan di sisi kanan. */
|
|
1490
|
+
actions: PropTypes10.node,
|
|
1491
|
+
/** ClassName tambahan. */
|
|
1492
|
+
className: PropTypes10.string
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
// src/presentation/organisms/DataTable.jsx
|
|
1496
|
+
import React11, { useMemo as useMemo2, useState as useState5, useCallback as useCallback4 } from "react";
|
|
1497
|
+
import PropTypes11 from "prop-types";
|
|
1498
|
+
function DataTable({
|
|
1499
|
+
columns,
|
|
1500
|
+
data = [],
|
|
1501
|
+
labels = {},
|
|
1502
|
+
dateLocale = "id-ID",
|
|
1503
|
+
searchable = true,
|
|
1504
|
+
sortable = true,
|
|
1505
|
+
pageSize = 10,
|
|
1506
|
+
emptyLabel = "No data",
|
|
1507
|
+
searchPlaceholder = "Search...",
|
|
1508
|
+
className = ""
|
|
1509
|
+
}) {
|
|
1510
|
+
const [searchQuery, setSearchQuery] = useState5("");
|
|
1511
|
+
const [sortField, setSortField] = useState5(null);
|
|
1512
|
+
const [sortDirection, setSortDirection] = useState5("asc");
|
|
1513
|
+
const [currentPage, setCurrentPage] = useState5(1);
|
|
1514
|
+
const filteredData = useMemo2(() => {
|
|
1515
|
+
if (!searchQuery.trim()) return data;
|
|
1516
|
+
const query = searchQuery.toLowerCase();
|
|
1517
|
+
return data.filter(
|
|
1518
|
+
(row) => columns.some((col) => {
|
|
1519
|
+
const value = row[col.accessor];
|
|
1520
|
+
return value != null && String(value).toLowerCase().includes(query);
|
|
1521
|
+
})
|
|
1522
|
+
);
|
|
1523
|
+
}, [data, searchQuery, columns]);
|
|
1524
|
+
const sortedData = useMemo2(() => {
|
|
1525
|
+
if (!sortField) return filteredData;
|
|
1526
|
+
const sorted = [...filteredData].sort((a, b) => {
|
|
1527
|
+
const aVal = a[sortField] ?? "";
|
|
1528
|
+
const bVal = b[sortField] ?? "";
|
|
1529
|
+
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
1530
|
+
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
|
|
1531
|
+
}
|
|
1532
|
+
const comparison = String(aVal).localeCompare(String(bVal), void 0, {
|
|
1533
|
+
numeric: true
|
|
1534
|
+
});
|
|
1535
|
+
return sortDirection === "asc" ? comparison : -comparison;
|
|
1536
|
+
});
|
|
1537
|
+
return sorted;
|
|
1538
|
+
}, [filteredData, sortField, sortDirection]);
|
|
1539
|
+
const totalPages = Math.max(1, Math.ceil(sortedData.length / pageSize));
|
|
1540
|
+
const safePage = Math.min(currentPage, totalPages);
|
|
1541
|
+
const paginatedData = useMemo2(() => {
|
|
1542
|
+
const start = (safePage - 1) * pageSize;
|
|
1543
|
+
return sortedData.slice(start, start + pageSize);
|
|
1544
|
+
}, [sortedData, safePage, pageSize]);
|
|
1545
|
+
const handleSearch = useCallback4((value) => {
|
|
1546
|
+
setSearchQuery(value);
|
|
1547
|
+
setCurrentPage(1);
|
|
1548
|
+
}, []);
|
|
1549
|
+
const handleSort = useCallback4(
|
|
1550
|
+
(accessor) => {
|
|
1551
|
+
if (!sortable) return;
|
|
1552
|
+
if (sortField === accessor) {
|
|
1553
|
+
setSortDirection((prev) => prev === "asc" ? "desc" : "asc");
|
|
1554
|
+
} else {
|
|
1555
|
+
setSortField(accessor);
|
|
1556
|
+
setSortDirection("asc");
|
|
1557
|
+
}
|
|
1558
|
+
setCurrentPage(1);
|
|
1559
|
+
},
|
|
1560
|
+
[sortable, sortField]
|
|
1561
|
+
);
|
|
1562
|
+
const handlePrevPage = useCallback4(() => {
|
|
1563
|
+
setCurrentPage((prev) => Math.max(1, prev - 1));
|
|
1564
|
+
}, []);
|
|
1565
|
+
const handleNextPage = useCallback4(() => {
|
|
1566
|
+
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
|
|
1567
|
+
}, [totalPages]);
|
|
1568
|
+
return /* @__PURE__ */ React11.createElement("div", { className: `space-y-3 ${className}` }, searchable ? /* @__PURE__ */ React11.createElement(
|
|
1569
|
+
SearchBar,
|
|
1570
|
+
{
|
|
1571
|
+
value: searchQuery,
|
|
1572
|
+
onSearch: handleSearch,
|
|
1573
|
+
placeholder: searchPlaceholder,
|
|
1574
|
+
className: "max-w-sm"
|
|
1575
|
+
}
|
|
1576
|
+
) : null, /* @__PURE__ */ React11.createElement("div", { className: "overflow-x-auto" }, /* @__PURE__ */ React11.createElement("table", { className: "min-w-full text-sm border-collapse" }, /* @__PURE__ */ React11.createElement("thead", null, /* @__PURE__ */ React11.createElement("tr", { className: "bg-slate-100 dark:bg-slate-800 text-left" }, columns.map((column) => /* @__PURE__ */ React11.createElement(
|
|
1577
|
+
"th",
|
|
1578
|
+
{
|
|
1579
|
+
key: column.id,
|
|
1580
|
+
className: [
|
|
1581
|
+
"p-3",
|
|
1582
|
+
sortable ? "cursor-pointer select-none hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" : ""
|
|
1583
|
+
].join(" "),
|
|
1584
|
+
onClick: () => handleSort(column.accessor)
|
|
1585
|
+
},
|
|
1586
|
+
/* @__PURE__ */ React11.createElement("div", { className: "flex items-center gap-1" }, /* @__PURE__ */ React11.createElement("span", null, labels[column.label] || column.label), sortable && sortField === column.accessor ? /* @__PURE__ */ React11.createElement(
|
|
1587
|
+
Icon,
|
|
1588
|
+
{
|
|
1589
|
+
name: sortDirection === "asc" ? "ArrowUp" : "ArrowDown",
|
|
1590
|
+
size: 14,
|
|
1591
|
+
className: "text-blue-500"
|
|
1592
|
+
}
|
|
1593
|
+
) : null)
|
|
1594
|
+
)))), /* @__PURE__ */ React11.createElement("tbody", null, paginatedData.map((row) => /* @__PURE__ */ React11.createElement(
|
|
1595
|
+
"tr",
|
|
1596
|
+
{
|
|
1597
|
+
key: row.id,
|
|
1598
|
+
className: "border-b border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
|
1599
|
+
},
|
|
1600
|
+
columns.map((column) => /* @__PURE__ */ React11.createElement("td", { key: `${row.id}-${column.id}`, className: "p-3" }, renderCell(column, row, dateLocale)))
|
|
1601
|
+
)), paginatedData.length === 0 ? /* @__PURE__ */ React11.createElement("tr", null, /* @__PURE__ */ React11.createElement(
|
|
1602
|
+
"td",
|
|
1603
|
+
{
|
|
1604
|
+
className: "p-6 text-center text-slate-500",
|
|
1605
|
+
colSpan: columns.length
|
|
1606
|
+
},
|
|
1607
|
+
emptyLabel
|
|
1608
|
+
)) : null))), sortedData.length > pageSize ? /* @__PURE__ */ React11.createElement("div", { className: "flex items-center justify-between px-1" }, /* @__PURE__ */ React11.createElement("span", { className: "text-xs text-slate-500" }, (safePage - 1) * pageSize + 1, "\u2013", Math.min(safePage * pageSize, sortedData.length), " of", " ", sortedData.length), /* @__PURE__ */ React11.createElement("div", { className: "flex items-center gap-1" }, /* @__PURE__ */ React11.createElement(
|
|
1609
|
+
"button",
|
|
1610
|
+
{
|
|
1611
|
+
type: "button",
|
|
1612
|
+
className: "p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",
|
|
1613
|
+
onClick: handlePrevPage,
|
|
1614
|
+
disabled: safePage <= 1,
|
|
1615
|
+
"aria-label": "Previous page"
|
|
1616
|
+
},
|
|
1617
|
+
/* @__PURE__ */ React11.createElement(Icon, { name: "ChevronLeft", size: 16 })
|
|
1618
|
+
), /* @__PURE__ */ React11.createElement("span", { className: "text-xs text-slate-600 dark:text-slate-300 min-w-[60px] text-center" }, safePage, " / ", totalPages), /* @__PURE__ */ React11.createElement(
|
|
1619
|
+
"button",
|
|
1620
|
+
{
|
|
1621
|
+
type: "button",
|
|
1622
|
+
className: "p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",
|
|
1623
|
+
onClick: handleNextPage,
|
|
1624
|
+
disabled: safePage >= totalPages,
|
|
1625
|
+
"aria-label": "Next page"
|
|
1626
|
+
},
|
|
1627
|
+
/* @__PURE__ */ React11.createElement(Icon, { name: "ChevronRight", size: 16 })
|
|
1628
|
+
))) : null);
|
|
1629
|
+
}
|
|
1630
|
+
function renderCell(column, row, dateLocale) {
|
|
1631
|
+
const value = row[column.accessor];
|
|
1632
|
+
if (column.type === "date") return formatDate(value, dateLocale);
|
|
1633
|
+
if (column.type === "currency") return `Rp ${formatIDR(value)}`;
|
|
1634
|
+
if (column.type === "statusBadge") {
|
|
1635
|
+
const status = row[column.statusAccessor] || "pending";
|
|
1636
|
+
return /* @__PURE__ */ React11.createElement(Badge, { status }, value);
|
|
1637
|
+
}
|
|
1638
|
+
return value || "-";
|
|
1639
|
+
}
|
|
1640
|
+
DataTable.propTypes = {
|
|
1641
|
+
/** Definisi kolom tabel. */
|
|
1642
|
+
columns: PropTypes11.arrayOf(
|
|
1643
|
+
PropTypes11.shape({
|
|
1644
|
+
id: PropTypes11.string.isRequired,
|
|
1645
|
+
label: PropTypes11.string.isRequired,
|
|
1646
|
+
accessor: PropTypes11.string.isRequired,
|
|
1647
|
+
type: PropTypes11.oneOf(["date", "currency", "statusBadge"]),
|
|
1648
|
+
statusAccessor: PropTypes11.string
|
|
1649
|
+
})
|
|
1650
|
+
).isRequired,
|
|
1651
|
+
/** Array data baris. */
|
|
1652
|
+
data: PropTypes11.arrayOf(PropTypes11.object),
|
|
1653
|
+
/** Objek label i18n. */
|
|
1654
|
+
labels: PropTypes11.object,
|
|
1655
|
+
/** Locale untuk format tanggal. */
|
|
1656
|
+
dateLocale: PropTypes11.string,
|
|
1657
|
+
/** Apakah tabel mendukung pencarian. */
|
|
1658
|
+
searchable: PropTypes11.bool,
|
|
1659
|
+
/** Apakah tabel mendukung pengurutan. */
|
|
1660
|
+
sortable: PropTypes11.bool,
|
|
1661
|
+
/** Jumlah baris per halaman. */
|
|
1662
|
+
pageSize: PropTypes11.number,
|
|
1663
|
+
/** Label saat data kosong. */
|
|
1664
|
+
emptyLabel: PropTypes11.string,
|
|
1665
|
+
/** Placeholder pencarian. */
|
|
1666
|
+
searchPlaceholder: PropTypes11.string,
|
|
1667
|
+
/** ClassName tambahan. */
|
|
1668
|
+
className: PropTypes11.string
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
// src/presentation/organisms/ChartCard.jsx
|
|
1672
|
+
import React12 from "react";
|
|
1673
|
+
import PropTypes12 from "prop-types";
|
|
1674
|
+
import {
|
|
1675
|
+
Area,
|
|
1676
|
+
AreaChart,
|
|
1677
|
+
Bar,
|
|
1678
|
+
BarChart,
|
|
1679
|
+
CartesianGrid,
|
|
1680
|
+
Cell,
|
|
1681
|
+
Legend,
|
|
1682
|
+
Pie,
|
|
1683
|
+
PieChart as PieChart2,
|
|
1684
|
+
ResponsiveContainer,
|
|
1685
|
+
Tooltip,
|
|
1686
|
+
XAxis,
|
|
1687
|
+
YAxis
|
|
1688
|
+
} from "recharts";
|
|
1689
|
+
var COLORS = [
|
|
1690
|
+
"#0ea5e9",
|
|
1691
|
+
"#a78bfa",
|
|
1692
|
+
"#f59e0b",
|
|
1693
|
+
"#10b981",
|
|
1694
|
+
"#ef4444",
|
|
1695
|
+
"#22d3ee"
|
|
1696
|
+
];
|
|
1697
|
+
function ChartCard({
|
|
1698
|
+
widget,
|
|
1699
|
+
labels,
|
|
1700
|
+
loading,
|
|
1701
|
+
filters,
|
|
1702
|
+
chartData,
|
|
1703
|
+
className = ""
|
|
1704
|
+
}) {
|
|
1705
|
+
const content = (() => {
|
|
1706
|
+
if (loading) return /* @__PURE__ */ React12.createElement(SkeletonLoader, { className: "h-64" });
|
|
1707
|
+
if (widget.type === "dailyArea") {
|
|
1708
|
+
return renderDailyArea(labels, filters, chartData.dailyTrends);
|
|
1709
|
+
}
|
|
1710
|
+
if (widget.type === "statusPie") {
|
|
1711
|
+
return renderPie(chartData.statusDistribution);
|
|
1712
|
+
}
|
|
1713
|
+
if (widget.type === "audiencePie") {
|
|
1714
|
+
return renderPie(chartData.audienceDistribution);
|
|
1715
|
+
}
|
|
1716
|
+
if (widget.type === "topPackagesBar") {
|
|
1717
|
+
return renderTopPackages(chartData.topPackages, labels, filters.sortPkgBy);
|
|
1718
|
+
}
|
|
1719
|
+
return /* @__PURE__ */ React12.createElement("div", { className: "text-sm text-slate-500" }, "Unsupported chart type.");
|
|
1720
|
+
})();
|
|
1721
|
+
return /* @__PURE__ */ React12.createElement("div", { className: `card p-4 ${className}` }, /* @__PURE__ */ React12.createElement(
|
|
1722
|
+
ChartHeader,
|
|
1723
|
+
{
|
|
1724
|
+
title: labels[widget.label] || widget.label,
|
|
1725
|
+
icon: widget.icon
|
|
1726
|
+
}
|
|
1727
|
+
), content);
|
|
1728
|
+
}
|
|
1729
|
+
function renderDailyArea(labels, filters, dailyData = []) {
|
|
1730
|
+
return /* @__PURE__ */ React12.createElement(ResponsiveContainer, { width: "100%", height: 256 }, /* @__PURE__ */ React12.createElement(AreaChart, { data: dailyData }, /* @__PURE__ */ React12.createElement(CartesianGrid, { strokeDasharray: "3 3" }), /* @__PURE__ */ React12.createElement(XAxis, { dataKey: "label" }), /* @__PURE__ */ React12.createElement(YAxis, { yAxisId: "left" }), /* @__PURE__ */ React12.createElement(YAxis, { yAxisId: "right", orientation: "right" }), /* @__PURE__ */ React12.createElement(
|
|
1731
|
+
Tooltip,
|
|
1732
|
+
{
|
|
1733
|
+
formatter: (value, name) => name === "revenue" ? [`Rp ${formatIDR(value)}`, labels.revenueMetric] : [value, labels.bookingsMetric]
|
|
1734
|
+
}
|
|
1735
|
+
), /* @__PURE__ */ React12.createElement(Legend, null), /* @__PURE__ */ React12.createElement(
|
|
1736
|
+
Area,
|
|
1737
|
+
{
|
|
1738
|
+
yAxisId: "left",
|
|
1739
|
+
type: "monotone",
|
|
1740
|
+
dataKey: "count",
|
|
1741
|
+
name: labels.confirmedBookingMetric,
|
|
1742
|
+
stroke: "#0ea5e9",
|
|
1743
|
+
fill: "#0ea5e9",
|
|
1744
|
+
fillOpacity: 0.3
|
|
1745
|
+
}
|
|
1746
|
+
), /* @__PURE__ */ React12.createElement(
|
|
1747
|
+
Area,
|
|
1748
|
+
{
|
|
1749
|
+
yAxisId: "right",
|
|
1750
|
+
type: "monotone",
|
|
1751
|
+
dataKey: "revenue",
|
|
1752
|
+
name: labels.confirmedRevenueMetric,
|
|
1753
|
+
stroke: "#a78bfa",
|
|
1754
|
+
fill: "#a78bfa",
|
|
1755
|
+
fillOpacity: 0.3
|
|
1756
|
+
}
|
|
1757
|
+
), filters.includePendingOverlay ? /* @__PURE__ */ React12.createElement(
|
|
1758
|
+
Area,
|
|
1759
|
+
{
|
|
1760
|
+
yAxisId: "left",
|
|
1761
|
+
type: "monotone",
|
|
1762
|
+
dataKey: "pendingCount",
|
|
1763
|
+
name: labels.pendingMetric,
|
|
1764
|
+
stroke: "#ef4444",
|
|
1765
|
+
fill: "#ef4444",
|
|
1766
|
+
fillOpacity: 0.1,
|
|
1767
|
+
strokeDasharray: "4 4"
|
|
1768
|
+
}
|
|
1769
|
+
) : null));
|
|
1770
|
+
}
|
|
1771
|
+
function renderPie(data = []) {
|
|
1772
|
+
return /* @__PURE__ */ React12.createElement(ResponsiveContainer, { width: "100%", height: 256 }, /* @__PURE__ */ React12.createElement(PieChart2, null, /* @__PURE__ */ React12.createElement(
|
|
1773
|
+
Pie,
|
|
1774
|
+
{
|
|
1775
|
+
data,
|
|
1776
|
+
dataKey: "count",
|
|
1777
|
+
nameKey: "label",
|
|
1778
|
+
innerRadius: 60,
|
|
1779
|
+
outerRadius: 100,
|
|
1780
|
+
paddingAngle: 2
|
|
1781
|
+
},
|
|
1782
|
+
data.map((_, index) => /* @__PURE__ */ React12.createElement(
|
|
1783
|
+
Cell,
|
|
1784
|
+
{
|
|
1785
|
+
key: `${index}-${data[index].label}`,
|
|
1786
|
+
fill: COLORS[index % COLORS.length]
|
|
1787
|
+
}
|
|
1788
|
+
))
|
|
1789
|
+
), /* @__PURE__ */ React12.createElement(Tooltip, null), /* @__PURE__ */ React12.createElement(Legend, null)));
|
|
1790
|
+
}
|
|
1791
|
+
function renderTopPackages(data = [], labels, sortPkgBy) {
|
|
1792
|
+
return /* @__PURE__ */ React12.createElement(ResponsiveContainer, { width: "100%", height: 256 }, /* @__PURE__ */ React12.createElement(BarChart, { data, layout: "vertical" }, /* @__PURE__ */ React12.createElement(CartesianGrid, { strokeDasharray: "3 3" }), /* @__PURE__ */ React12.createElement(XAxis, { type: "number", allowDecimals: false }), /* @__PURE__ */ React12.createElement(YAxis, { type: "category", dataKey: "name", width: 120 }), /* @__PURE__ */ React12.createElement(
|
|
1793
|
+
Tooltip,
|
|
1794
|
+
{
|
|
1795
|
+
formatter: (value) => sortPkgBy === "revenue" ? [`Rp ${formatIDR(value)}`, labels.revenueMetric] : [value, labels.bookingsMetric]
|
|
1796
|
+
}
|
|
1797
|
+
), /* @__PURE__ */ React12.createElement(
|
|
1798
|
+
Bar,
|
|
1799
|
+
{
|
|
1800
|
+
dataKey: "value",
|
|
1801
|
+
name: sortPkgBy === "revenue" ? labels.revenueMetric : labels.bookingsMetric,
|
|
1802
|
+
fill: "#10b981",
|
|
1803
|
+
radius: [4, 4, 4, 4]
|
|
1804
|
+
}
|
|
1805
|
+
)));
|
|
1806
|
+
}
|
|
1807
|
+
ChartCard.propTypes = {
|
|
1808
|
+
/** Konfigurasi widget chart. */
|
|
1809
|
+
widget: PropTypes12.shape({
|
|
1810
|
+
id: PropTypes12.string.isRequired,
|
|
1811
|
+
type: PropTypes12.string.isRequired,
|
|
1812
|
+
label: PropTypes12.string.isRequired,
|
|
1813
|
+
icon: PropTypes12.string
|
|
1814
|
+
}).isRequired,
|
|
1815
|
+
/** Objek label i18n. */
|
|
1816
|
+
labels: PropTypes12.object.isRequired,
|
|
1817
|
+
/** Apakah sedang loading. */
|
|
1818
|
+
loading: PropTypes12.bool,
|
|
1819
|
+
/** State filter dashboard. */
|
|
1820
|
+
filters: PropTypes12.object,
|
|
1821
|
+
/** Data untuk semua jenis chart. */
|
|
1822
|
+
chartData: PropTypes12.object,
|
|
1823
|
+
/** ClassName tambahan. */
|
|
1824
|
+
className: PropTypes12.string
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
// src/presentation/organisms/FilterPanel.jsx
|
|
1828
|
+
import React13 from "react";
|
|
1829
|
+
import PropTypes13 from "prop-types";
|
|
1830
|
+
var STATUS_OPTIONS = ["confirmed", "pending", "all"];
|
|
1831
|
+
var AUDIENCE_OPTIONS = ["", "domestic", "foreign"];
|
|
1832
|
+
var DAY_PRESETS = [7, 30, 90, 0];
|
|
1833
|
+
var SORT_BY_OPTIONS = ["bookings", "revenue"];
|
|
1834
|
+
var SORT_DIR_OPTIONS = ["desc", "asc"];
|
|
1835
|
+
function FilterPanel({
|
|
1836
|
+
filters,
|
|
1837
|
+
labels,
|
|
1838
|
+
onFilterChange,
|
|
1839
|
+
onResetFilters,
|
|
1840
|
+
className = ""
|
|
1841
|
+
}) {
|
|
1842
|
+
return /* @__PURE__ */ React13.createElement("div", { className: `flex flex-col gap-2 ${className}` }, /* @__PURE__ */ React13.createElement("div", { className: "grid grid-cols-1 lg:grid-cols-6 gap-2" }, /* @__PURE__ */ React13.createElement(
|
|
1843
|
+
"select",
|
|
1844
|
+
{
|
|
1845
|
+
className: "px-3 py-2 rounded-2xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm",
|
|
1846
|
+
value: filters.statusScope,
|
|
1847
|
+
onChange: (event) => onFilterChange("statusScope", event.target.value)
|
|
1848
|
+
},
|
|
1849
|
+
STATUS_OPTIONS.map((status) => /* @__PURE__ */ React13.createElement("option", { key: status, value: status }, status === "confirmed" ? labels.confirmedOnly : status === "pending" ? labels.pendingOnly : labels.allStatus))
|
|
1850
|
+
), /* @__PURE__ */ React13.createElement("label", { className: "inline-flex items-center gap-2 px-3 py-2 rounded-2xl border border-slate-300 dark:border-slate-600" }, /* @__PURE__ */ React13.createElement(
|
|
1851
|
+
Input,
|
|
1852
|
+
{
|
|
1853
|
+
type: "checkbox",
|
|
1854
|
+
checked: filters.includePendingOverlay,
|
|
1855
|
+
onChange: (event) => onFilterChange("includePendingOverlay", event.target.checked)
|
|
1856
|
+
}
|
|
1857
|
+
), /* @__PURE__ */ React13.createElement(Typography, { variant: "body" }, labels.showPendingOverlay)), /* @__PURE__ */ React13.createElement(
|
|
1858
|
+
"select",
|
|
1859
|
+
{
|
|
1860
|
+
className: "px-3 py-2 rounded-2xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm",
|
|
1861
|
+
value: filters.audience,
|
|
1862
|
+
onChange: (event) => onFilterChange("audience", event.target.value)
|
|
1863
|
+
},
|
|
1864
|
+
AUDIENCE_OPTIONS.map((audience) => /* @__PURE__ */ React13.createElement("option", { key: audience || "all", value: audience }, audience === "domestic" ? labels.audienceDomestic : audience === "foreign" ? labels.audienceForeign : labels.allAudience))
|
|
1865
|
+
), /* @__PURE__ */ React13.createElement(
|
|
1866
|
+
"select",
|
|
1867
|
+
{
|
|
1868
|
+
className: "px-3 py-2 rounded-2xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm",
|
|
1869
|
+
value: String(filters.daysPreset),
|
|
1870
|
+
onChange: (event) => onFilterChange("daysPreset", Number(event.target.value))
|
|
1871
|
+
},
|
|
1872
|
+
DAY_PRESETS.map((days) => /* @__PURE__ */ React13.createElement("option", { key: String(days), value: String(days) }, days === 0 ? labels.customDate : labels.dayLabel(days)))
|
|
1873
|
+
), /* @__PURE__ */ React13.createElement(
|
|
1874
|
+
DateRangeFilter,
|
|
1875
|
+
{
|
|
1876
|
+
dateFrom: filters.dateFrom || "",
|
|
1877
|
+
dateTo: filters.dateTo || "",
|
|
1878
|
+
onRangeChange: ({ dateFrom, dateTo }) => {
|
|
1879
|
+
if (dateFrom !== filters.dateFrom) onFilterChange("dateFrom", dateFrom);
|
|
1880
|
+
if (dateTo !== filters.dateTo) onFilterChange("dateTo", dateTo);
|
|
1881
|
+
},
|
|
1882
|
+
disabled: filters.daysPreset !== 0,
|
|
1883
|
+
className: "lg:col-span-2"
|
|
1884
|
+
}
|
|
1885
|
+
)), /* @__PURE__ */ React13.createElement("div", { className: "flex items-center justify-between" }, /* @__PURE__ */ React13.createElement(Button, { variant: "secondary", size: "sm", onClick: onResetFilters }, labels.reset), /* @__PURE__ */ React13.createElement("div", { className: "flex items-center gap-2" }, /* @__PURE__ */ React13.createElement(Typography, { variant: "caption" }, labels.topSort, ":"), /* @__PURE__ */ React13.createElement(
|
|
1886
|
+
"select",
|
|
1887
|
+
{
|
|
1888
|
+
className: "px-2 py-1.5 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm",
|
|
1889
|
+
value: filters.sortPkgBy,
|
|
1890
|
+
onChange: (event) => onFilterChange("sortPkgBy", event.target.value)
|
|
1891
|
+
},
|
|
1892
|
+
SORT_BY_OPTIONS.map((sortBy) => /* @__PURE__ */ React13.createElement("option", { key: sortBy, value: sortBy }, sortBy === "bookings" ? labels.sortBookings : labels.sortRevenue))
|
|
1893
|
+
), /* @__PURE__ */ React13.createElement(
|
|
1894
|
+
"select",
|
|
1895
|
+
{
|
|
1896
|
+
className: "px-2 py-1.5 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm",
|
|
1897
|
+
value: filters.sortPkgDir,
|
|
1898
|
+
onChange: (event) => onFilterChange("sortPkgDir", event.target.value)
|
|
1899
|
+
},
|
|
1900
|
+
SORT_DIR_OPTIONS.map((direction) => /* @__PURE__ */ React13.createElement("option", { key: direction, value: direction }, direction === "desc" ? labels.sortDesc : labels.sortAsc))
|
|
1901
|
+
))));
|
|
1902
|
+
}
|
|
1903
|
+
FilterPanel.propTypes = {
|
|
1904
|
+
/** State filter saat ini. */
|
|
1905
|
+
filters: PropTypes13.shape({
|
|
1906
|
+
statusScope: PropTypes13.string,
|
|
1907
|
+
includePendingOverlay: PropTypes13.bool,
|
|
1908
|
+
audience: PropTypes13.string,
|
|
1909
|
+
daysPreset: PropTypes13.number,
|
|
1910
|
+
dateFrom: PropTypes13.string,
|
|
1911
|
+
dateTo: PropTypes13.string,
|
|
1912
|
+
sortPkgBy: PropTypes13.string,
|
|
1913
|
+
sortPkgDir: PropTypes13.string
|
|
1914
|
+
}).isRequired,
|
|
1915
|
+
/** Objek label i18n. */
|
|
1916
|
+
labels: PropTypes13.object.isRequired,
|
|
1917
|
+
/** Callback saat filter berubah (field, value). */
|
|
1918
|
+
onFilterChange: PropTypes13.func.isRequired,
|
|
1919
|
+
/** Callback saat filter di-reset. */
|
|
1920
|
+
onResetFilters: PropTypes13.func.isRequired,
|
|
1921
|
+
/** ClassName tambahan. */
|
|
1922
|
+
className: PropTypes13.string
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
// src/presentation/organisms/SidebarNavigation.jsx
|
|
1926
|
+
import React14 from "react";
|
|
1927
|
+
import PropTypes14 from "prop-types";
|
|
1928
|
+
function SidebarNavigation({
|
|
1929
|
+
items = [],
|
|
1930
|
+
activeItem = "",
|
|
1931
|
+
onItemClick,
|
|
1932
|
+
logo = null,
|
|
1933
|
+
className = ""
|
|
1934
|
+
}) {
|
|
1935
|
+
return /* @__PURE__ */ React14.createElement(
|
|
1936
|
+
"aside",
|
|
1937
|
+
{
|
|
1938
|
+
className: `flex flex-col w-60 min-h-screen bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 ${className}`
|
|
1939
|
+
},
|
|
1940
|
+
logo ? /* @__PURE__ */ React14.createElement("div", { className: "px-4 py-4 border-b border-slate-100 dark:border-slate-800" }, logo) : null,
|
|
1941
|
+
/* @__PURE__ */ React14.createElement("nav", { className: "flex-1 px-2 py-3 space-y-0.5", "aria-label": "Sidebar navigation" }, items.map((item) => {
|
|
1942
|
+
const isActive = item.id === activeItem;
|
|
1943
|
+
return /* @__PURE__ */ React14.createElement(
|
|
1944
|
+
"button",
|
|
1945
|
+
{
|
|
1946
|
+
key: item.id,
|
|
1947
|
+
type: "button",
|
|
1948
|
+
id: `sidebar-nav-${item.id}`,
|
|
1949
|
+
onClick: () => onItemClick == null ? void 0 : onItemClick(item.id),
|
|
1950
|
+
"aria-current": isActive ? "page" : void 0,
|
|
1951
|
+
className: [
|
|
1952
|
+
"w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors text-left",
|
|
1953
|
+
isActive ? "bg-blue-50 dark:bg-blue-950/60 text-blue-700 dark:text-blue-300" : "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100"
|
|
1954
|
+
].join(" ")
|
|
1955
|
+
},
|
|
1956
|
+
item.icon ? /* @__PURE__ */ React14.createElement(
|
|
1957
|
+
Icon,
|
|
1958
|
+
{
|
|
1959
|
+
name: item.icon,
|
|
1960
|
+
size: 18,
|
|
1961
|
+
className: isActive ? "text-blue-600 dark:text-blue-400" : "text-slate-400 dark:text-slate-500"
|
|
1962
|
+
}
|
|
1963
|
+
) : null,
|
|
1964
|
+
/* @__PURE__ */ React14.createElement("span", null, item.label),
|
|
1965
|
+
isActive ? /* @__PURE__ */ React14.createElement("span", { className: "ml-auto w-1.5 h-1.5 rounded-full bg-blue-500" }) : null
|
|
1966
|
+
);
|
|
1967
|
+
}))
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
SidebarNavigation.propTypes = {
|
|
1971
|
+
/** Daftar item menu navigasi. */
|
|
1972
|
+
items: PropTypes14.arrayOf(
|
|
1973
|
+
PropTypes14.shape({
|
|
1974
|
+
id: PropTypes14.string.isRequired,
|
|
1975
|
+
label: PropTypes14.string.isRequired,
|
|
1976
|
+
icon: PropTypes14.string
|
|
1977
|
+
})
|
|
1978
|
+
),
|
|
1979
|
+
/** ID item yang sedang aktif. */
|
|
1980
|
+
activeItem: PropTypes14.string,
|
|
1981
|
+
/** Callback saat item diklik, menerima id item sebagai argumen. */
|
|
1982
|
+
onItemClick: PropTypes14.func,
|
|
1983
|
+
/** Konten logo/brand di atas sidebar. */
|
|
1984
|
+
logo: PropTypes14.node,
|
|
1985
|
+
/** ClassName tambahan. */
|
|
1986
|
+
className: PropTypes14.string
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
// src/presentation/organisms/TopbarHeader.jsx
|
|
1990
|
+
import React15 from "react";
|
|
1991
|
+
import PropTypes15 from "prop-types";
|
|
1992
|
+
function TopbarHeader({
|
|
1993
|
+
title = "",
|
|
1994
|
+
actions = null,
|
|
1995
|
+
leading = null,
|
|
1996
|
+
className = ""
|
|
1997
|
+
}) {
|
|
1998
|
+
return /* @__PURE__ */ React15.createElement(
|
|
1999
|
+
"header",
|
|
2000
|
+
{
|
|
2001
|
+
className: `sticky top-0 z-30 flex items-center justify-between h-14 px-4 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 ${className}`
|
|
2002
|
+
},
|
|
2003
|
+
/* @__PURE__ */ React15.createElement("div", { className: "flex items-center gap-3" }, leading, title ? /* @__PURE__ */ React15.createElement(Typography, { variant: "h2", className: "truncate" }, title) : null),
|
|
2004
|
+
actions ? /* @__PURE__ */ React15.createElement("div", { className: "flex items-center gap-2" }, actions) : null
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
TopbarHeader.propTypes = {
|
|
2008
|
+
/** Judul halaman yang ditampilkan. */
|
|
2009
|
+
title: PropTypes15.string,
|
|
2010
|
+
/** Elemen aksi di sisi kanan (tombol, avatar, dsb.). */
|
|
2011
|
+
actions: PropTypes15.node,
|
|
2012
|
+
/** Elemen di sisi kiri sebelum judul (misal: hamburger menu). */
|
|
2013
|
+
leading: PropTypes15.node,
|
|
2014
|
+
/** ClassName tambahan. */
|
|
2015
|
+
className: PropTypes15.string
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
// src/presentation/templates/DashboardLayout.jsx
|
|
2019
|
+
import React16 from "react";
|
|
2020
|
+
import PropTypes16 from "prop-types";
|
|
2021
|
+
function DashboardLayout({
|
|
2022
|
+
header = null,
|
|
2023
|
+
sidebar = null,
|
|
2024
|
+
children,
|
|
2025
|
+
className = ""
|
|
2026
|
+
}) {
|
|
2027
|
+
return /* @__PURE__ */ React16.createElement("div", { className: `min-h-screen bg-slate-50 dark:bg-slate-900 ${className}` }, header, /* @__PURE__ */ React16.createElement("div", { className: "flex" }, sidebar, /* @__PURE__ */ React16.createElement("main", { className: "flex-1 container mx-auto px-4 py-3 space-y-4" }, children)));
|
|
2028
|
+
}
|
|
2029
|
+
DashboardLayout.propTypes = {
|
|
2030
|
+
/** Konten header atas. */
|
|
2031
|
+
header: PropTypes16.node,
|
|
2032
|
+
/** Konten sidebar. */
|
|
2033
|
+
sidebar: PropTypes16.node,
|
|
2034
|
+
/** Konten utama dashboard. */
|
|
2035
|
+
children: PropTypes16.node.isRequired,
|
|
2036
|
+
/** ClassName tambahan. */
|
|
2037
|
+
className: PropTypes16.string
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// src/presentation/ReusableDashboardView.jsx
|
|
2041
|
+
import React17 from "react";
|
|
2042
|
+
import PropTypes17 from "prop-types";
|
|
2043
|
+
function ReusableDashboardView({
|
|
2044
|
+
config,
|
|
2045
|
+
labels,
|
|
2046
|
+
loading,
|
|
2047
|
+
error,
|
|
2048
|
+
filters,
|
|
2049
|
+
onFilterChange,
|
|
2050
|
+
onResetFilters,
|
|
2051
|
+
onRefresh,
|
|
2052
|
+
data,
|
|
2053
|
+
dateLocale,
|
|
2054
|
+
liveUpdateEnabled
|
|
2055
|
+
}) {
|
|
2056
|
+
return /* @__PURE__ */ React17.createElement("div", { className: "container mt-3 space-y-4" }, /* @__PURE__ */ React17.createElement("div", null, /* @__PURE__ */ React17.createElement("div", { className: "rounded-2xl border border-slate-200/60 dark:border-slate-800/60 backdrop-blur-md px-3 sm:px-4 py-2 glass shadow-smooth" }, /* @__PURE__ */ React17.createElement("div", { className: "flex flex-col gap-2" }, /* @__PURE__ */ React17.createElement("div", { className: "flex flex-wrap items-center justify-between gap-2" }, /* @__PURE__ */ React17.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React17.createElement(Typography, { variant: "h1" }, labels.title), liveUpdateEnabled ? /* @__PURE__ */ React17.createElement(Badge, { status: "success" }, labels.liveUpdate) : null), /* @__PURE__ */ React17.createElement(
|
|
2057
|
+
Button,
|
|
2058
|
+
{
|
|
2059
|
+
variant: "secondary",
|
|
2060
|
+
size: "sm",
|
|
2061
|
+
onClick: () => onRefresh(),
|
|
2062
|
+
title: labels.refresh
|
|
2063
|
+
},
|
|
2064
|
+
/* @__PURE__ */ React17.createElement(Icon, { name: "RotateCcw", size: 16 })
|
|
2065
|
+
)), error ? /* @__PURE__ */ React17.createElement("div", { className: "rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300" }, /* @__PURE__ */ React17.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React17.createElement("span", null, error), /* @__PURE__ */ React17.createElement(Button, { variant: "secondary", size: "sm", onClick: () => onRefresh() }, labels.retry))) : null, /* @__PURE__ */ React17.createElement(
|
|
2066
|
+
FilterPanel,
|
|
2067
|
+
{
|
|
2068
|
+
filters,
|
|
2069
|
+
labels,
|
|
2070
|
+
onFilterChange,
|
|
2071
|
+
onResetFilters
|
|
2072
|
+
}
|
|
2073
|
+
)))), /* @__PURE__ */ React17.createElement("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" }, loading ? Array.from({ length: 4 }).map((_, index) => /* @__PURE__ */ React17.createElement(SkeletonLoader, { key: `stat-skeleton-${index}`, className: "h-28" })) : config.widgets.stats.map((widget) => /* @__PURE__ */ React17.createElement(
|
|
2074
|
+
StatCard,
|
|
2075
|
+
{
|
|
2076
|
+
key: widget.id,
|
|
2077
|
+
label: labels[widget.label] || widget.label,
|
|
2078
|
+
value: data.stats[widget.valueKey],
|
|
2079
|
+
icon: widget.icon,
|
|
2080
|
+
format: widget.format
|
|
2081
|
+
}
|
|
2082
|
+
))), /* @__PURE__ */ React17.createElement("div", { className: "grid grid-cols-1 lg:grid-cols-2 gap-4" }, config.widgets.charts.map((widget) => /* @__PURE__ */ React17.createElement(
|
|
2083
|
+
ChartCard,
|
|
2084
|
+
{
|
|
2085
|
+
key: widget.id,
|
|
2086
|
+
widget,
|
|
2087
|
+
labels,
|
|
2088
|
+
loading,
|
|
2089
|
+
filters,
|
|
2090
|
+
chartData: data.charts
|
|
2091
|
+
}
|
|
2092
|
+
))), /* @__PURE__ */ React17.createElement("div", { className: "card p-4" }, /* @__PURE__ */ React17.createElement(Typography, { variant: "h3", className: "flex items-center gap-2 mb-3" }, /* @__PURE__ */ React17.createElement(Icon, { name: config.widgets.table.icon, size: 18 }), labels[config.widgets.table.label] || config.widgets.table.label), loading ? /* @__PURE__ */ React17.createElement(SkeletonLoader, { className: "h-48" }) : /* @__PURE__ */ React17.createElement(
|
|
2093
|
+
DataTable,
|
|
2094
|
+
{
|
|
2095
|
+
columns: config.widgets.table.columns,
|
|
2096
|
+
data: data.table.recentBookings,
|
|
2097
|
+
labels,
|
|
2098
|
+
dateLocale,
|
|
2099
|
+
emptyLabel: labels[config.widgets.table.emptyLabel] || config.widgets.table.emptyLabel,
|
|
2100
|
+
searchable: true,
|
|
2101
|
+
sortable: true,
|
|
2102
|
+
pageSize: 10
|
|
2103
|
+
}
|
|
2104
|
+
)));
|
|
2105
|
+
}
|
|
2106
|
+
ReusableDashboardView.propTypes = {
|
|
2107
|
+
/** Konfigurasi widget dashboard. */
|
|
2108
|
+
config: PropTypes17.object.isRequired,
|
|
2109
|
+
/** Objek label i18n. */
|
|
2110
|
+
labels: PropTypes17.object.isRequired,
|
|
2111
|
+
/** Status loading data. */
|
|
2112
|
+
loading: PropTypes17.bool,
|
|
2113
|
+
/** Pesan error jika ada. */
|
|
2114
|
+
error: PropTypes17.string,
|
|
2115
|
+
/** State filter dashboard. */
|
|
2116
|
+
filters: PropTypes17.object,
|
|
2117
|
+
/** Callback perubahan filter. */
|
|
2118
|
+
onFilterChange: PropTypes17.func.isRequired,
|
|
2119
|
+
/** Callback reset filter. */
|
|
2120
|
+
onResetFilters: PropTypes17.func.isRequired,
|
|
2121
|
+
/** Callback refresh data. */
|
|
2122
|
+
onRefresh: PropTypes17.func.isRequired,
|
|
2123
|
+
/** Data dashboard. */
|
|
2124
|
+
data: PropTypes17.object,
|
|
2125
|
+
/** Locale untuk format tanggal. */
|
|
2126
|
+
dateLocale: PropTypes17.string,
|
|
2127
|
+
/** Apakah live update aktif. */
|
|
2128
|
+
liveUpdateEnabled: PropTypes17.bool
|
|
2129
|
+
};
|
|
2130
|
+
|
|
2131
|
+
// src/utils/labels.js
|
|
2132
|
+
function createDashboardLabels(t) {
|
|
2133
|
+
const labels = {
|
|
2134
|
+
title: t("admin.dashboard.title", { defaultValue: "Dashboard" }),
|
|
2135
|
+
refresh: t("admin.dashboard.refresh", { defaultValue: "Refresh" }),
|
|
2136
|
+
liveUpdate: t("admin.dashboard.liveUpdate", { defaultValue: "Live update" }),
|
|
2137
|
+
loadFailed: t("admin.dashboard.loadFailed", {
|
|
2138
|
+
defaultValue: "Failed to load dashboard data."
|
|
2139
|
+
}),
|
|
2140
|
+
retry: t("admin.dashboard.retry", { defaultValue: "Retry" }),
|
|
2141
|
+
confirmedOnly: t("admin.dashboard.filters.confirmedOnly", {
|
|
2142
|
+
defaultValue: "Confirmed only"
|
|
2143
|
+
}),
|
|
2144
|
+
pendingOnly: t("admin.dashboard.filters.pendingOnly", {
|
|
2145
|
+
defaultValue: "Pending only"
|
|
2146
|
+
}),
|
|
2147
|
+
allStatus: t("admin.dashboard.filters.allStatus", {
|
|
2148
|
+
defaultValue: "All status"
|
|
2149
|
+
}),
|
|
2150
|
+
showPendingOverlay: t("admin.dashboard.filters.showPendingOverlay", {
|
|
2151
|
+
defaultValue: "Show pending overlay"
|
|
2152
|
+
}),
|
|
2153
|
+
allAudience: t("admin.dashboard.filters.allAudience", {
|
|
2154
|
+
defaultValue: "All audience"
|
|
2155
|
+
}),
|
|
2156
|
+
audienceDomestic: t("explore.domestic", { defaultValue: "Domestic" }),
|
|
2157
|
+
audienceForeign: t("explore.foreign", { defaultValue: "Foreign" }),
|
|
2158
|
+
customDate: t("admin.dashboard.filters.customDate", {
|
|
2159
|
+
defaultValue: "Custom"
|
|
2160
|
+
}),
|
|
2161
|
+
reset: t("admin.dashboard.filters.reset", { defaultValue: "Reset" }),
|
|
2162
|
+
topSort: t("admin.dashboard.filters.topSort", {
|
|
2163
|
+
defaultValue: "Top packages sort"
|
|
2164
|
+
}),
|
|
2165
|
+
sortBookings: t("admin.dashboard.filters.sortBookings", {
|
|
2166
|
+
defaultValue: "Bookings"
|
|
2167
|
+
}),
|
|
2168
|
+
sortRevenue: t("admin.dashboard.filters.sortRevenue", {
|
|
2169
|
+
defaultValue: "Revenue"
|
|
2170
|
+
}),
|
|
2171
|
+
sortDesc: t("admin.dashboard.filters.sortDesc", { defaultValue: "Desc" }),
|
|
2172
|
+
sortAsc: t("admin.dashboard.filters.sortAsc", { defaultValue: "Asc" }),
|
|
2173
|
+
confirmedBookings: t("admin.dashboard.cards.confirmedBookings", {
|
|
2174
|
+
defaultValue: "Confirmed Bookings"
|
|
2175
|
+
}),
|
|
2176
|
+
confirmedRevenue: t("admin.dashboard.cards.confirmedRevenue", {
|
|
2177
|
+
defaultValue: "Revenue (Confirmed)"
|
|
2178
|
+
}),
|
|
2179
|
+
avgRevenue: t("admin.dashboard.cards.avgRevenue", {
|
|
2180
|
+
defaultValue: "Avg Revenue / Booking"
|
|
2181
|
+
}),
|
|
2182
|
+
conversionRate: t("admin.dashboard.cards.conversionRate", {
|
|
2183
|
+
defaultValue: "Conversion Rate"
|
|
2184
|
+
}),
|
|
2185
|
+
totalProducts: t("admin.dashboard.cards.totalProducts", {
|
|
2186
|
+
defaultValue: "Total Products"
|
|
2187
|
+
}),
|
|
2188
|
+
totalCustomers: t("admin.dashboard.cards.totalCustomers", {
|
|
2189
|
+
defaultValue: "Total Customers"
|
|
2190
|
+
}),
|
|
2191
|
+
dailyTrends: t("admin.dashboard.sections.dailyTrends", {
|
|
2192
|
+
defaultValue: "Daily Trends"
|
|
2193
|
+
}),
|
|
2194
|
+
statusDistribution: t("admin.dashboard.sections.statusDistribution", {
|
|
2195
|
+
defaultValue: "Status Distribution"
|
|
2196
|
+
}),
|
|
2197
|
+
audienceDistribution: t("admin.dashboard.sections.audienceDistribution", {
|
|
2198
|
+
defaultValue: "Audience Distribution"
|
|
2199
|
+
}),
|
|
2200
|
+
topPackages: t("admin.dashboard.sections.topPackages", {
|
|
2201
|
+
defaultValue: "Top Packages"
|
|
2202
|
+
}),
|
|
2203
|
+
recentBookings: t("admin.dashboard.sections.recentBookings", {
|
|
2204
|
+
defaultValue: "Recent Bookings"
|
|
2205
|
+
}),
|
|
2206
|
+
date: t("admin.dashboard.table.date", { defaultValue: "Date" }),
|
|
2207
|
+
customer: t("admin.dashboard.table.customer", { defaultValue: "Customer" }),
|
|
2208
|
+
package: t("admin.dashboard.table.package", { defaultValue: "Package" }),
|
|
2209
|
+
audience: t("admin.dashboard.table.audience", { defaultValue: "Audience" }),
|
|
2210
|
+
total: t("admin.dashboard.table.total", { defaultValue: "Total" }),
|
|
2211
|
+
status: t("admin.dashboard.table.status", { defaultValue: "Status" }),
|
|
2212
|
+
noRecentBookings: t("admin.dashboard.emptyRecent", {
|
|
2213
|
+
defaultValue: "No recent bookings"
|
|
2214
|
+
}),
|
|
2215
|
+
bookingsMetric: t("admin.dashboard.metrics.bookings", {
|
|
2216
|
+
defaultValue: "Bookings"
|
|
2217
|
+
}),
|
|
2218
|
+
revenueMetric: t("admin.dashboard.metrics.revenue", {
|
|
2219
|
+
defaultValue: "Revenue"
|
|
2220
|
+
}),
|
|
2221
|
+
pendingMetric: t("admin.dashboard.metrics.pendingBookings", {
|
|
2222
|
+
defaultValue: "Bookings (Pending)"
|
|
2223
|
+
}),
|
|
2224
|
+
confirmedBookingMetric: t("admin.dashboard.metrics.confirmedBookings", {
|
|
2225
|
+
defaultValue: "Bookings (Confirmed)"
|
|
2226
|
+
}),
|
|
2227
|
+
confirmedRevenueMetric: t("admin.dashboard.metrics.confirmedRevenue", {
|
|
2228
|
+
defaultValue: "Revenue (Confirmed)"
|
|
2229
|
+
}),
|
|
2230
|
+
unknownAudience: t("admin.dashboard.filters.unknownAudience", {
|
|
2231
|
+
defaultValue: "Unknown"
|
|
2232
|
+
})
|
|
2233
|
+
};
|
|
2234
|
+
labels.dayLabel = (count) => t("admin.dashboard.filters.dayWindow", {
|
|
2235
|
+
count,
|
|
2236
|
+
defaultValue: "{{count}} days"
|
|
2237
|
+
});
|
|
2238
|
+
labels.formatStatusLabel = (status) => {
|
|
2239
|
+
const normalized = String(status || "pending").toLowerCase();
|
|
2240
|
+
const defaultValue = normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
2241
|
+
return t(`admin.dashboard.status.${normalized}`, { defaultValue });
|
|
2242
|
+
};
|
|
2243
|
+
labels.formatAudienceLabel = (value) => {
|
|
2244
|
+
if (value === "domestic") return labels.audienceDomestic;
|
|
2245
|
+
if (value === "foreign") return labels.audienceForeign;
|
|
2246
|
+
return labels.unknownAudience;
|
|
2247
|
+
};
|
|
2248
|
+
return labels;
|
|
2249
|
+
}
|
|
2250
|
+
export {
|
|
2251
|
+
Badge,
|
|
2252
|
+
Button,
|
|
2253
|
+
ChartCard,
|
|
2254
|
+
ChartHeader,
|
|
2255
|
+
DashboardLayout,
|
|
2256
|
+
DataTable,
|
|
2257
|
+
DateRangeFilter,
|
|
2258
|
+
FilterPanel,
|
|
2259
|
+
Icon,
|
|
2260
|
+
Input,
|
|
2261
|
+
ReusableDashboardView,
|
|
2262
|
+
SearchBar,
|
|
2263
|
+
SidebarNavigation,
|
|
2264
|
+
SkeletonLoader,
|
|
2265
|
+
StatCard,
|
|
2266
|
+
TopbarHeader,
|
|
2267
|
+
Typography,
|
|
2268
|
+
adaptCidikaDashboardData,
|
|
2269
|
+
adaptDummyUmkmData,
|
|
2270
|
+
adaptTokoSepatuData,
|
|
2271
|
+
buildDayBuckets,
|
|
2272
|
+
cidikaWidgetConfig,
|
|
2273
|
+
createCidikaSupabaseSource,
|
|
2274
|
+
createDashboardLabels,
|
|
2275
|
+
createDefaultFilters,
|
|
2276
|
+
createEmptyDashboardData,
|
|
2277
|
+
createEmptyDummyUmkmData,
|
|
2278
|
+
createEmptyTokoSepatuData,
|
|
2279
|
+
createTokoSepatuSupabaseSource,
|
|
2280
|
+
dummyUmkmWidgetConfig,
|
|
2281
|
+
formatDate,
|
|
2282
|
+
formatIDR,
|
|
2283
|
+
formatYYYYMMDD,
|
|
2284
|
+
resolveDateRange,
|
|
2285
|
+
resolveIcon,
|
|
2286
|
+
shortId,
|
|
2287
|
+
sortMapEntries,
|
|
2288
|
+
toNumber,
|
|
2289
|
+
tokoSepatuWidgetConfig,
|
|
2290
|
+
useRealtimeUpdate,
|
|
2291
|
+
useReusableDashboard
|
|
2292
|
+
};
|
|
2293
|
+
//# sourceMappingURL=index.js.map
|