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