From 27beb38e557deb2b1f581b2e8b52667b55353a0f Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 9 Aug 2026 16:45:53 +0700 Subject: [PATCH] feat: add revenue trend analysis feature with chart visualization in Analysis component --- app/Http/Controllers/AnalysisController.php | 2 + app/Services/AnalysisService.php | 19 ++ chart.tsx | 221 -------------------- database/seeders/RolePermissionSeeder.php | 4 +- resources/js/pages/admin/analysis/index.tsx | 98 ++++++++- 5 files changed, 119 insertions(+), 225 deletions(-) delete mode 100644 chart.tsx diff --git a/app/Http/Controllers/AnalysisController.php b/app/Http/Controllers/AnalysisController.php index 365b60e..d5099a9 100644 --- a/app/Http/Controllers/AnalysisController.php +++ b/app/Http/Controllers/AnalysisController.php @@ -43,6 +43,7 @@ public function index(Request $request): Response $topProducts = $this->service->getTopProducts($startDate, $endDate); $marketingSales = $this->service->getMarketingSales($startDate, $endDate); $orderStats = $this->service->getOrderStats($startDate, $endDate); + $revenueTrend = $this->service->getRevenueTrend($startDate, $endDate); return Inertia::render('admin/analysis/index', [ 'filters' => [ @@ -68,6 +69,7 @@ public function index(Request $request): Response 'topProducts' => $topProducts, 'marketingSales' => $marketingSales, 'orderStats' => $orderStats, + 'revenueTrend' => $revenueTrend, ]); } } diff --git a/app/Services/AnalysisService.php b/app/Services/AnalysisService.php index 3e99a0f..ff78734 100644 --- a/app/Services/AnalysisService.php +++ b/app/Services/AnalysisService.php @@ -465,6 +465,25 @@ public function getTopProducts(?string $startDate, ?string $endDate): array ->toArray(); } + public function getRevenueTrend(?string $startDate, ?string $endDate): array + { + $query = Order::where('orders.status', OrderStatus::COMPLETED); + $this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at'); + + return (clone $query) + ->join('order_items', 'orders.id', '=', 'order_items.order_id') + ->selectRaw("DATE(orders.created_at) as date") + ->selectRaw('SUM(order_items.quantity) as qty') + ->groupBy(DB::raw("DATE(orders.created_at)")) + ->orderBy(DB::raw("DATE(orders.created_at)")) + ->get() + ->map(fn ($item) => [ + 'date' => $item->date, + 'qty' => (int) $item->qty, + ]) + ->toArray(); + } + public function getMarketingSales(?string $startDate, ?string $endDate): array { $query = Order::where('orders.status', OrderStatus::COMPLETED) diff --git a/chart.tsx b/chart.tsx deleted file mode 100644 index b177162..0000000 --- a/chart.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client" - -import * as React from "react" -import { Bar, BarChart, CartesianGrid, XAxis } from "recharts" - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart" - -export const description = "An interactive bar chart" - -const chartData = [ - { date: "2024-04-01", desktop: 222, mobile: 150 }, - { date: "2024-04-02", desktop: 97, mobile: 180 }, - { date: "2024-04-03", desktop: 167, mobile: 120 }, - { date: "2024-04-04", desktop: 242, mobile: 260 }, - { date: "2024-04-05", desktop: 373, mobile: 290 }, - { date: "2024-04-06", desktop: 301, mobile: 340 }, - { date: "2024-04-07", desktop: 245, mobile: 180 }, - { date: "2024-04-08", desktop: 409, mobile: 320 }, - { date: "2024-04-09", desktop: 59, mobile: 110 }, - { date: "2024-04-10", desktop: 261, mobile: 190 }, - { date: "2024-04-11", desktop: 327, mobile: 350 }, - { date: "2024-04-12", desktop: 292, mobile: 210 }, - { date: "2024-04-13", desktop: 342, mobile: 380 }, - { date: "2024-04-14", desktop: 137, mobile: 220 }, - { date: "2024-04-15", desktop: 120, mobile: 170 }, - { date: "2024-04-16", desktop: 138, mobile: 190 }, - { date: "2024-04-17", desktop: 446, mobile: 360 }, - { date: "2024-04-18", desktop: 364, mobile: 410 }, - { date: "2024-04-19", desktop: 243, mobile: 180 }, - { date: "2024-04-20", desktop: 89, mobile: 150 }, - { date: "2024-04-21", desktop: 137, mobile: 200 }, - { date: "2024-04-22", desktop: 224, mobile: 170 }, - { date: "2024-04-23", desktop: 138, mobile: 230 }, - { date: "2024-04-24", desktop: 387, mobile: 290 }, - { date: "2024-04-25", desktop: 215, mobile: 250 }, - { date: "2024-04-26", desktop: 75, mobile: 130 }, - { date: "2024-04-27", desktop: 383, mobile: 420 }, - { date: "2024-04-28", desktop: 122, mobile: 180 }, - { date: "2024-04-29", desktop: 315, mobile: 240 }, - { date: "2024-04-30", desktop: 454, mobile: 380 }, - { date: "2024-05-01", desktop: 165, mobile: 220 }, - { date: "2024-05-02", desktop: 293, mobile: 310 }, - { date: "2024-05-03", desktop: 247, mobile: 190 }, - { date: "2024-05-04", desktop: 385, mobile: 420 }, - { date: "2024-05-05", desktop: 481, mobile: 390 }, - { date: "2024-05-06", desktop: 498, mobile: 520 }, - { date: "2024-05-07", desktop: 388, mobile: 300 }, - { date: "2024-05-08", desktop: 149, mobile: 210 }, - { date: "2024-05-09", desktop: 227, mobile: 180 }, - { date: "2024-05-10", desktop: 293, mobile: 330 }, - { date: "2024-05-11", desktop: 335, mobile: 270 }, - { date: "2024-05-12", desktop: 197, mobile: 240 }, - { date: "2024-05-13", desktop: 197, mobile: 160 }, - { date: "2024-05-14", desktop: 448, mobile: 490 }, - { date: "2024-05-15", desktop: 473, mobile: 380 }, - { date: "2024-05-16", desktop: 338, mobile: 400 }, - { date: "2024-05-17", desktop: 499, mobile: 420 }, - { date: "2024-05-18", desktop: 315, mobile: 350 }, - { date: "2024-05-19", desktop: 235, mobile: 180 }, - { date: "2024-05-20", desktop: 177, mobile: 230 }, - { date: "2024-05-21", desktop: 82, mobile: 140 }, - { date: "2024-05-22", desktop: 81, mobile: 120 }, - { date: "2024-05-23", desktop: 252, mobile: 290 }, - { date: "2024-05-24", desktop: 294, mobile: 220 }, - { date: "2024-05-25", desktop: 201, mobile: 250 }, - { date: "2024-05-26", desktop: 213, mobile: 170 }, - { date: "2024-05-27", desktop: 420, mobile: 460 }, - { date: "2024-05-28", desktop: 233, mobile: 190 }, - { date: "2024-05-29", desktop: 78, mobile: 130 }, - { date: "2024-05-30", desktop: 340, mobile: 280 }, - { date: "2024-05-31", desktop: 178, mobile: 230 }, - { date: "2024-06-01", desktop: 178, mobile: 200 }, - { date: "2024-06-02", desktop: 470, mobile: 410 }, - { date: "2024-06-03", desktop: 103, mobile: 160 }, - { date: "2024-06-04", desktop: 439, mobile: 380 }, - { date: "2024-06-05", desktop: 88, mobile: 140 }, - { date: "2024-06-06", desktop: 294, mobile: 250 }, - { date: "2024-06-07", desktop: 323, mobile: 370 }, - { date: "2024-06-08", desktop: 385, mobile: 320 }, - { date: "2024-06-09", desktop: 438, mobile: 480 }, - { date: "2024-06-10", desktop: 155, mobile: 200 }, - { date: "2024-06-11", desktop: 92, mobile: 150 }, - { date: "2024-06-12", desktop: 492, mobile: 420 }, - { date: "2024-06-13", desktop: 81, mobile: 130 }, - { date: "2024-06-14", desktop: 426, mobile: 380 }, - { date: "2024-06-15", desktop: 307, mobile: 350 }, - { date: "2024-06-16", desktop: 371, mobile: 310 }, - { date: "2024-06-17", desktop: 475, mobile: 520 }, - { date: "2024-06-18", desktop: 107, mobile: 170 }, - { date: "2024-06-19", desktop: 341, mobile: 290 }, - { date: "2024-06-20", desktop: 408, mobile: 450 }, - { date: "2024-06-21", desktop: 169, mobile: 210 }, - { date: "2024-06-22", desktop: 317, mobile: 270 }, - { date: "2024-06-23", desktop: 480, mobile: 530 }, - { date: "2024-06-24", desktop: 132, mobile: 180 }, - { date: "2024-06-25", desktop: 141, mobile: 190 }, - { date: "2024-06-26", desktop: 434, mobile: 380 }, - { date: "2024-06-27", desktop: 448, mobile: 490 }, - { date: "2024-06-28", desktop: 149, mobile: 200 }, - { date: "2024-06-29", desktop: 103, mobile: 160 }, - { date: "2024-06-30", desktop: 446, mobile: 400 }, -] - -const chartConfig = { - views: { - label: "Page Views", - }, - desktop: { - label: "Desktop", - color: "var(--chart-2)", - }, - mobile: { - label: "Mobile", - color: "var(--chart-1)", - }, -} satisfies ChartConfig - -export function ChartBarInteractive() { - const [activeChart, setActiveChart] = - React.useState("desktop") - - const total = React.useMemo( - () => ({ - desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0), - mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0), - }), - [] - ) - - return ( - - -
- Bar Chart - Interactive - - Showing total visitors for the last 3 months - -
-
- {["desktop", "mobile"].map((key) => { - const chart = key as keyof typeof chartConfig - return ( - - ) - })} -
-
- - - - - { - const date = new Date(value) - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }) - }} - /> - { - return new Date(value).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }) - }} - /> - } - /> - - - - -
- ) -} diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 0cd6382..dbdd8a4 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -15,7 +15,7 @@ public function run(): void $permissions = [ 'dashboard' => ['view', 'attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status', 'cash'], - 'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'], + 'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'revenue_trend', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'], 'employees' => ['view', 'create', 'update', 'delete', 'toggle_status', 'reset_password'], 'attendances' => ['view', 'create', 'delete', 'manage'], 'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'], @@ -82,6 +82,7 @@ public function run(): void 'analysis.cash', 'analysis.product_stock', 'analysis.revenue', + 'analysis.revenue_trend', 'analysis.expense', 'analysis.busy_hours', 'analysis.profit_orders', @@ -368,6 +369,7 @@ public function run(): void 'analysis.cash', 'analysis.product_stock', 'analysis.revenue', + 'analysis.revenue_trend', 'analysis.expense', 'analysis.busy_hours', 'analysis.profit_orders', diff --git a/resources/js/pages/admin/analysis/index.tsx b/resources/js/pages/admin/analysis/index.tsx index ef5a4a3..5f515c0 100644 --- a/resources/js/pages/admin/analysis/index.tsx +++ b/resources/js/pages/admin/analysis/index.tsx @@ -26,6 +26,8 @@ import { BarChart, CartesianGrid, Cell, + Line, + LineChart, Pie, PieChart, ResponsiveContainer, @@ -182,6 +184,10 @@ type AnalysisProps = { count: number; }>; }; + revenueTrend: Array<{ + date: string; + qty: number; + }>; }; const revenueChartConfig = { @@ -230,6 +236,15 @@ const expenseChartConfig = { const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const; +const revenueTrendChartConfig = { + qty: { + label: 'Qty', + color: 'var(--chart-1)', + }, +} satisfies ChartConfig; + +const revenueTrendKeys = ['qty'] as const; + const CHANNEL_COLORS: Record = { store: '#22c55e', shopee: '#ee4d2d', @@ -400,6 +415,7 @@ export default function Analysis({ topProducts, marketingSales, orderStats, + revenueTrend, }: AnalysisProps) { const { can, hasAnyRole, hasRole } = useCan(); const [startDate, setStartDate] = useState(initialFilters.start_date ?? ''); @@ -495,15 +511,15 @@ export default function Analysis({ const sectionOrder = useMemo(() => { if (hasAnyRole(['owner', 'developer'])) { - return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topSuppliers: 10, topProducts: 11, topCustomers: 12, busyHours: 13 }; + return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 }; } if (hasAnyRole(['admin_toko', 'direktur'])) { - return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topProducts: 10, topCustomers: 11, busyHours: 12 }; + return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topProducts: 11, topCustomers: 12, busyHours: 13 }; } if (hasRole('marketing')) { - return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 }; + return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 }; } return {}; @@ -889,6 +905,82 @@ export default function Analysis({ )} + {can('analysis.revenue_trend') && ( + + +
+ Trend Pendapatan +
+
+ {revenueTrendKeys.map((key) => { + const total = revenueTrend.reduce((acc, item) => acc + (item[key] ?? 0), 0); + return ( + + ); + })} +
+
+ + {revenueTrend.length > 0 ? ( + + + + { + const date = new Date(value); + return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }); + }} + /> + { + return new Date(value).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' }); + }} + formatter={(value, name, item, index) => ( + <> +
+ {revenueTrendChartConfig[item.dataKey as keyof typeof revenueTrendChartConfig]?.label ?? name} + + {Number(value).toLocaleString('id-ID')} + + + )} + /> + } + /> + + + + ) : ( +
Belum ada data trend pendapatan
+ )} + + + )} + {can('analysis.marketing_sales') && (