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:
Yoga Pangestu 2026-08-09 12:52:12 +07:00
parent a1ba9564c7
commit 2bb814702a
6 changed files with 887 additions and 167 deletions

View File

@ -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,
]);
}
}

View File

@ -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,

View File

@ -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
View File

@ -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

View 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,
}

View File

@ -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