From 2bb814702a30429658fce35c92e9de52ca339847 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 9 Aug 2026 12:52:12 +0700 Subject: [PATCH] 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. --- app/Http/Controllers/DashboardController.php | 22 +- app/Services/DashboardService.php | 96 +++-- package.json | 2 +- pnpm-lock.yaml | 330 ++++++++++++++++ resources/js/components/ui/chart.tsx | 372 +++++++++++++++++++ resources/js/pages/dashboard.tsx | 232 ++++++------ 6 files changed, 887 insertions(+), 167 deletions(-) create mode 100644 resources/js/components/ui/chart.tsx diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index bf76223..6eed6df 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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, ]); } } diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php index 2cea3b2..9409792 100644 --- a/app/Services/DashboardService.php +++ b/app/Services/DashboardService.php @@ -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, diff --git a/package.json b/package.json index e7d2904..3c0dfab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 373cfbf..ff4e593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/resources/js/components/ui/chart.tsx b/resources/js/components/ui/chart.tsx new file mode 100644 index 0000000..6947c2e --- /dev/null +++ b/resources/js/components/ui/chart.tsx @@ -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 } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + 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 ( + +
+ + + {children} + +
+
+ ) +} + +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 ( +