feat: add ChartContainer and related components for enhanced charting capabilities in the dashboard
- Introduced ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, and ChartLegendContent components to streamline chart rendering and tooltip management. - Replaced existing donut chart implementation with a new pie chart component utilizing the new charting system. - Updated dashboard to utilize the new chart components, improving maintainability and visual consistency. - Refactored chart data handling and configuration for better integration with the new charting components.
This commit is contained in:
parent
a1ba9564c7
commit
2bb814702a
@ -17,22 +17,14 @@ public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$attendance = $this->service->getAttendanceStats();
|
||||
$revenueSummary = $this->service->getRevenueSummary();
|
||||
$expenseSummary = $this->service->getExpenseSummary();
|
||||
$orderStats = $this->service->getOrderStats();
|
||||
$todayAttendance = $this->service->getTodayAttendance($user);
|
||||
$isOnLeave = $this->service->isOnLeave($user);
|
||||
$canCheckIn = $user->employee !== null;
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'attendance' => $attendance,
|
||||
'revenueSummary' => $revenueSummary,
|
||||
'expenseSummary' => $expenseSummary,
|
||||
'orderStats' => $orderStats,
|
||||
'todayAttendance' => $todayAttendance,
|
||||
'isOnLeave' => $isOnLeave,
|
||||
'canCheckIn' => $canCheckIn,
|
||||
'attendance' => $this->service->getAttendanceStats(),
|
||||
'revenueSummary' => $this->service->getRevenueSummary(),
|
||||
'expenseSummary' => $this->service->getExpenseSummary(),
|
||||
'orderStats' => $this->service->getOrderStats(),
|
||||
'todayAttendance' => $this->service->getTodayAttendance($user),
|
||||
'isOnLeave' => $this->service->isOnLeave($user),
|
||||
'canCheckIn' => $user->employee !== null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,14 +20,54 @@ public function getAttendanceStats(): array
|
||||
{
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
|
||||
$totalEmployees = Employee::whereHas(
|
||||
'user',
|
||||
fn($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas(
|
||||
'roles',
|
||||
fn($r) => $r
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
)->count();
|
||||
|
||||
$present = Attendance::where('attendance_date', $today)->count();
|
||||
$present = Attendance::where('attendance_date', $today)
|
||||
->whereHas(
|
||||
'employee.user',
|
||||
fn($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas(
|
||||
'roles',
|
||||
fn($r) => $r
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
)->count();
|
||||
|
||||
$onLeave = LeaveRequest::approved()
|
||||
->where('start_date', '<=', $today)
|
||||
->where('end_date', '>=', $today)
|
||||
->count();
|
||||
->whereHas(
|
||||
'employee.user',
|
||||
fn($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas(
|
||||
'roles',
|
||||
fn($r) => $r
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
)->count();
|
||||
|
||||
$absent = max(0, $totalEmployees - $present - $onLeave);
|
||||
|
||||
@ -43,21 +83,34 @@ public function getAttendanceStats(): array
|
||||
public function getRevenueSummary(): array
|
||||
{
|
||||
$today = Carbon::now()->toDateString();
|
||||
$baseQuery = Order::where('status', OrderStatus::COMPLETED)
|
||||
->whereDate('created_at', $today);
|
||||
|
||||
$stats = Order::where('status', OrderStatus::COMPLETED)
|
||||
->whereDate('created_at', $today)
|
||||
$stats = (clone $baseQuery)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
|
||||
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
|
||||
->first();
|
||||
|
||||
$marketplaceFees = (clone $baseQuery)
|
||||
->whereNotNull('marketplace_settings_snapshot')
|
||||
->selectRaw("COALESCE(SUM(JSON_EXTRACT(marketplace_settings_snapshot, '$.total_fee_amount')), 0) as total")
|
||||
->first()
|
||||
->total;
|
||||
|
||||
$negoDiff = (clone $baseQuery)
|
||||
->whereNotNull('nego_price')
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as total')
|
||||
->first()
|
||||
->total;
|
||||
|
||||
return [
|
||||
'total_revenue' => (int) $stats->total_revenue,
|
||||
'total_discount' => (int) $stats->total_discount,
|
||||
'total_marketplace_fees' => 0,
|
||||
'total_deduction' => (int) $stats->total_discount,
|
||||
'total_cogs' => (int) $stats->total_cogs,
|
||||
'total_deduction' => (int) $marketplaceFees + (int) $negoDiff,
|
||||
'total_orders' => (int) $stats->total_orders,
|
||||
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@ -65,32 +118,26 @@ public function getExpenseSummary(): array
|
||||
{
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$expenses = Expense::whereDate('created_at', $today)
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$cashAdvanceTotal = EmployeeAdvance::where('status', 'paid')
|
||||
->whereDate('created_at', $today)
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$expenseTotal = Expense::whereDate('created_at', $today)
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$advanceTotal = EmployeeAdvance::whereDate('created_at', $today)
|
||||
->approved()
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total' => (int) $expenses->total,
|
||||
'purchase_total' => 0,
|
||||
'total' => (int) $expenseTotal->total + (int) $advanceTotal->total,
|
||||
'expense_total' => (int) $expenseTotal->total,
|
||||
'advance_total' => (int) ($cashAdvanceTotal->total ?? 0),
|
||||
'advance_total' => (int) $advanceTotal->total,
|
||||
];
|
||||
}
|
||||
|
||||
public function getOrderStats(): array
|
||||
{
|
||||
$today = Carbon::now()->toDateString();
|
||||
$baseQuery = Order::where('status', OrderStatus::COMPLETED)
|
||||
->whereDate('created_at', $today);
|
||||
$baseQuery = Order::whereDate('created_at', $today);
|
||||
|
||||
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
|
||||
$count = (clone $baseQuery)->where('channel', $channel)->count();
|
||||
@ -116,8 +163,7 @@ public function getOrderStats(): array
|
||||
];
|
||||
});
|
||||
|
||||
$byMarketing = Order::where('status', OrderStatus::COMPLETED)
|
||||
->whereDate('created_at', $today)
|
||||
$byMarketing = (clone $baseQuery)
|
||||
->whereNotNull('marketing_id')
|
||||
->select('marketing_id')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
@ -125,7 +171,7 @@ public function getOrderStats(): array
|
||||
->groupBy('marketing_id')
|
||||
->with('marketing:id')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
->map(fn($item) => [
|
||||
'name' => $item->marketing?->userProfile->full_name ?? '-',
|
||||
'count' => $item->count,
|
||||
'total' => (int) $item->total,
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.10.1",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.16.0",
|
||||
"sonner": "^2.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
|
||||
330
pnpm-lock.yaml
generated
330
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ importers:
|
||||
'@laravel/passkeys':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(react@19.2.8)
|
||||
'@point-of-sale/receipt-printer-encoder':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@radix-ui/react-avatar':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@ -131,6 +134,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.8(react@19.2.8)
|
||||
recharts:
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@16.13.1)(react@19.2.8)(redux@5.0.1)
|
||||
shadcn:
|
||||
specifier: ^4.16.0
|
||||
version: 4.16.0(typescript@5.9.3)
|
||||
@ -793,6 +799,9 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@canvas/image-data@1.1.0':
|
||||
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
|
||||
|
||||
'@date-fns/tz@1.5.0':
|
||||
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||
|
||||
@ -1017,6 +1026,12 @@ packages:
|
||||
'@oxc-project/types@0.139.0':
|
||||
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
||||
|
||||
'@point-of-sale/codepage-encoder@3.0.2':
|
||||
resolution: {integrity: sha512-Sx+0sE/XgPtMUNKScHpwTIZiHNd/+Mg0udACQJVwPsxH9kUAgIkRi9kRlmWBMCruwOJadjKLyKs8MO8Bh+VS/A==}
|
||||
|
||||
'@point-of-sale/receipt-printer-encoder@3.0.3':
|
||||
resolution: {integrity: sha512-2+xgs6rwNfrkEw9g4LkgQS6k29qnmo3+hOV7Z2xHNBFn8h4FrwquZwL/dBOTwgmQ9sWEYBkj/bQkg9Rz6lL99w==}
|
||||
|
||||
'@radix-ui/number@1.1.3':
|
||||
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
|
||||
|
||||
@ -1707,6 +1722,17 @@ packages:
|
||||
'@radix-ui/rect@1.1.3':
|
||||
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0':
|
||||
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.1.5':
|
||||
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -2019,6 +2045,12 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@stylistic/eslint-plugin@5.10.0':
|
||||
resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@ -2152,6 +2184,33 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
@ -2184,6 +2243,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2':
|
||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||
|
||||
@ -2569,6 +2631,12 @@ packages:
|
||||
caniuse-lite@1.0.30001806:
|
||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||
|
||||
canvas-dither@1.0.1:
|
||||
resolution: {integrity: sha512-wT1RnV2y9SauibcsfMMCagQTnXmyQq2nJXuX5tTEvo0sXm1vVbJ2r8QxSC3fRQMY3ZgZVq1j54cOvmO4u+Lu/A==}
|
||||
|
||||
canvas-flatten@1.0.1:
|
||||
resolution: {integrity: sha512-UhHeKeWGS23Bm5teZiHk6NEOme1OKCpv6arFovV89dydt0Xz+U4HkPXAKBZS/hmpR15x5U9tDj1ieMQMTwvCAA==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -2688,6 +2756,50 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -2724,6 +2836,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
dedent@1.7.2:
|
||||
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
|
||||
peerDependencies:
|
||||
@ -3016,6 +3131,9 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
eventsource-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -3293,6 +3411,12 @@ packages:
|
||||
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.16:
|
||||
resolution: {integrity: sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@ -3314,6 +3438,10 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ip-address@10.3.1:
|
||||
resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==}
|
||||
engines: {node: '>= 12'}
|
||||
@ -4245,6 +4373,18 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-redux@9.3.0:
|
||||
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.18.0:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -4287,6 +4427,22 @@ packages:
|
||||
resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
recharts@3.8.0:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -4321,9 +4477,16 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
reselect@5.2.0:
|
||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||
|
||||
resize-image-data@0.3.1:
|
||||
resolution: {integrity: sha512-6hVRn2S6W1cdycreA6Vth5XRN2NnGs7/RnVpxNw/1OCK8aCoevRFH2WprmQRZDnnH3e6awLv2tTIPuv7/7xeGg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@ -4791,6 +4954,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-plugin-full-reload@1.2.0:
|
||||
resolution: {integrity: sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==}
|
||||
|
||||
@ -5721,6 +5887,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@canvas/image-data@1.1.0': {}
|
||||
|
||||
'@date-fns/tz@1.5.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.8)':
|
||||
@ -5994,6 +6162,16 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.139.0': {}
|
||||
|
||||
'@point-of-sale/codepage-encoder@3.0.2': {}
|
||||
|
||||
'@point-of-sale/receipt-printer-encoder@3.0.3':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
'@point-of-sale/codepage-encoder': 3.0.2
|
||||
canvas-dither: 1.0.1
|
||||
canvas-flatten: 1.0.1
|
||||
resize-image-data: 0.3.1
|
||||
|
||||
'@radix-ui/number@1.1.3': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.7': {}
|
||||
@ -6741,6 +6919,18 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.3': {}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.16
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.2.0
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1)
|
||||
|
||||
'@rolldown/binding-android-arm64@1.1.5':
|
||||
optional: true
|
||||
|
||||
@ -6927,6 +7117,10 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
|
||||
@ -7052,6 +7246,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
@ -7080,6 +7298,8 @@ snapshots:
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
|
||||
@ -7489,6 +7709,10 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001806: {}
|
||||
|
||||
canvas-dither@1.0.1: {}
|
||||
|
||||
canvas-flatten@1.0.1: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@ -7596,6 +7820,44 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@ -7628,6 +7890,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
dedent@1.7.2: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
@ -8015,6 +8279,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
eventsource-parser@3.1.0: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
@ -8334,6 +8600,10 @@ snapshots:
|
||||
|
||||
ignore@7.0.6: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.16: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@ -8354,6 +8624,8 @@ snapshots:
|
||||
hasown: 2.0.4
|
||||
side-channel: 1.1.1
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ip-address@10.3.1: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
@ -9146,6 +9418,15 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.8
|
||||
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8):
|
||||
@ -9185,6 +9466,32 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
recharts@3.8.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@16.13.1)(react@19.2.8)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.50.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-is: 16.13.1
|
||||
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@ -9230,8 +9537,14 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
reselect@5.2.0: {}
|
||||
|
||||
resize-image-data@0.3.1:
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@ -9836,6 +10149,23 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-plugin-full-reload@1.2.0:
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
|
||||
372
resources/js/components/ui/chart.tsx
Normal file
372
resources/js/components/ui/chart.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { StatCard } from '@/components/card/stat-card';
|
||||
import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
|
||||
import { CameraCapture } from '@/components/inputs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -20,7 +21,7 @@ import {
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { Pie, PieChart } from 'recharts';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type TodayAttendance = {
|
||||
@ -48,14 +49,12 @@ type DashboardProps = {
|
||||
revenueSummary: {
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
total_marketplace_fees: number;
|
||||
total_cogs: number;
|
||||
total_deduction: number;
|
||||
total_orders: number;
|
||||
avg_order: number;
|
||||
};
|
||||
expenseSummary: {
|
||||
total: number;
|
||||
purchase_total: number;
|
||||
expense_total: number;
|
||||
advance_total: number;
|
||||
};
|
||||
@ -88,38 +87,6 @@ type DashboardProps = {
|
||||
canCheckIn: boolean;
|
||||
};
|
||||
|
||||
const CHART_COLORS = ['#60a5fa', '#f97316', '#22c55e', '#a855f7', '#ef4444'];
|
||||
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
payload: {
|
||||
label?: string;
|
||||
name?: string;
|
||||
count: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
function ChartTooltip({ active, payload }: CustomTooltipProps) {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-1.5 shadow-xl">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{data.label ?? data.name ?? ''}</span>
|
||||
<span className="ml-2 font-medium">{data.count.toLocaleString('id-ID')}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
attendance: attendanceStats,
|
||||
revenueSummary,
|
||||
@ -129,7 +96,7 @@ export default function Dashboard({
|
||||
isOnLeave,
|
||||
canCheckIn,
|
||||
}: DashboardProps) {
|
||||
const { can } = useCan();
|
||||
const { can, hasRole } = useCan();
|
||||
const { auth } = usePage().props as { auth: { user?: { username?: string } } };
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [showCamera, setShowCamera] = useState(false);
|
||||
@ -327,23 +294,27 @@ export default function Dashboard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TodayAttendanceAlert
|
||||
todayAttendance={todayAttendance}
|
||||
isOnLeave={isOnLeave}
|
||||
canCheckIn={canCheckIn}
|
||||
locationLoading={locationLoading}
|
||||
onCheckIn={handleCheckIn}
|
||||
onCheckOut={handleCheckOut}
|
||||
/>
|
||||
|
||||
<Dialog open={showCamera} onOpenChange={setShowCamera}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<CameraCapture
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setShowCamera(false)}
|
||||
{can('attendances.create') && !hasRole('developer') && !hasRole('owner') && (
|
||||
<>
|
||||
<TodayAttendanceAlert
|
||||
todayAttendance={todayAttendance}
|
||||
isOnLeave={isOnLeave}
|
||||
canCheckIn={canCheckIn}
|
||||
locationLoading={locationLoading}
|
||||
onCheckIn={handleCheckIn}
|
||||
onCheckOut={handleCheckOut}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showCamera} onOpenChange={setShowCamera}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<CameraCapture
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setShowCamera(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleStatsCount > 0 && (
|
||||
<div className={statsGridClass}>
|
||||
@ -372,11 +343,11 @@ export default function Dashboard({
|
||||
items={[
|
||||
{
|
||||
label: 'Bersih',
|
||||
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_deduction)}`,
|
||||
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_cogs - revenueSummary.total_deduction)}`,
|
||||
},
|
||||
{
|
||||
label: 'Potongan',
|
||||
value: `Rp${formatRupiah(revenueSummary.total_marketplace_fees ?? 0)}`,
|
||||
value: `Rp${formatRupiah(revenueSummary.total_deduction)}`,
|
||||
},
|
||||
{
|
||||
label: 'Diskon',
|
||||
@ -410,51 +381,50 @@ export default function Dashboard({
|
||||
{visibleChartsCount > 0 && (
|
||||
<div className={chartsGridClass}>
|
||||
{can('dashboard.orders_channel') && (
|
||||
<DashboardDonutChart
|
||||
title="Pesanan per Channel Hari Ini"
|
||||
data={orderStats.by_channel.map((item, index) => ({
|
||||
...item,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Channel Hari Ini"
|
||||
data={orderStats.by_channel.map((item) => ({
|
||||
name: item.label,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
nameKey="name"
|
||||
/>
|
||||
)}
|
||||
|
||||
{can('dashboard.orders_payment') && (
|
||||
<DashboardDonutChart
|
||||
title="Pesanan per Pembayaran Hari Ini"
|
||||
data={orderStats.by_payment_type.map((item, index) => ({
|
||||
...item,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Pembayaran Hari Ini"
|
||||
data={orderStats.by_payment_type.map((item) => ({
|
||||
name: item.label,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
nameKey="name"
|
||||
/>
|
||||
)}
|
||||
|
||||
{can('dashboard.orders_marketing') && (
|
||||
<DashboardDonutChart
|
||||
title="Pesanan per Marketing Hari Ini"
|
||||
data={orderStats.by_marketing.map((item, index) => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Marketing Hari Ini"
|
||||
data={orderStats.by_marketing.map((item) => ({
|
||||
name: item.name,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
nameKey="name"
|
||||
/>
|
||||
)}
|
||||
|
||||
{can('dashboard.orders_status') && (
|
||||
<DashboardDonutChart
|
||||
title="Pesanan per Status Hari Ini"
|
||||
data={orderStats.by_status.map((item, index) => ({
|
||||
...item,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Status Hari Ini"
|
||||
data={orderStats.by_status.map((item) => ({
|
||||
name: item.label,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
nameKey="name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -464,68 +434,78 @@ export default function Dashboard({
|
||||
);
|
||||
}
|
||||
|
||||
type DonutChartItem = {
|
||||
label: string;
|
||||
type PieChartItem = {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DashboardDonutChartProps = {
|
||||
type DashboardPieChartProps = {
|
||||
title: string;
|
||||
data: DonutChartItem[];
|
||||
data: PieChartItem[];
|
||||
dataKey: string;
|
||||
nameKey: string;
|
||||
};
|
||||
|
||||
function DashboardDonutChart({ title, data }: DashboardDonutChartProps) {
|
||||
const PIE_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
];
|
||||
|
||||
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
|
||||
const hasData = data.length > 0 && data.some((d) => d.count > 0);
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const config: ChartConfig = {};
|
||||
data.forEach((item, index) => {
|
||||
config[item.name] = {
|
||||
label: item.name,
|
||||
color: PIE_COLORS[index % PIE_COLORS.length],
|
||||
};
|
||||
});
|
||||
return config;
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
fill: PIE_COLORS[data.indexOf(item) % PIE_COLORS.length],
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
{hasData ? (
|
||||
<>
|
||||
<div className="mx-auto aspect-square max-h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
innerRadius={50}
|
||||
strokeWidth={2}
|
||||
stroke="#374151"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color ?? CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-3">
|
||||
{data.map((item, index) => (
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2.5 rounded-full"
|
||||
style={{ backgroundColor: item.color ?? CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey={nameKey}
|
||||
hideLabel
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{item.label}</span>
|
||||
<span className="text-xs font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey={dataKey}
|
||||
nameKey={nameKey}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent nameKey={nameKey} />}
|
||||
className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center"
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data
|
||||
|
||||
Loading…
Reference in New Issue
Block a user