From 28f88bb69c20a3ca359b3be217da86b196d77d1a Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 16 Aug 2026 18:06:05 +0700 Subject: [PATCH] feat: add revenue by stock type calculation and update analysis components --- app/Http/Controllers/AnalysisController.php | 2 + app/Services/AnalysisService.php | 60 ++++++++++++++ resources/js/pages/admin/analysis/index.tsx | 92 +++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/app/Http/Controllers/AnalysisController.php b/app/Http/Controllers/AnalysisController.php index 5a620ae..84942f8 100644 --- a/app/Http/Controllers/AnalysisController.php +++ b/app/Http/Controllers/AnalysisController.php @@ -30,6 +30,7 @@ public function index(Request $request): Response $cashOverview = $this->service->getCashOverview($startDate, $endDate); $rawMaterialStock = $this->service->getRawMaterialStock(); $productStock = $this->service->getProductStock(); + $revenueByStockType = $this->service->getRevenueByStockType($startDate, $endDate, $user); $revenueSummary = $this->service->getRevenueSummary($startDate, $endDate, $user); $monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate, $user); $monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate, $user); @@ -56,6 +57,7 @@ public function index(Request $request): Response 'cashOverview' => $cashOverview, 'rawMaterialStock' => $rawMaterialStock, 'productStock' => $productStock, + 'revenueByStockType' => $revenueByStockType, 'revenueSummary' => $revenueSummary, 'monthlyRevenue' => $monthlyRevenue, 'monthlyRevenueByChannel' => $monthlyRevenueByChannel, diff --git a/app/Services/AnalysisService.php b/app/Services/AnalysisService.php index 0b6d862..dcbe1a0 100644 --- a/app/Services/AnalysisService.php +++ b/app/Services/AnalysisService.php @@ -9,6 +9,7 @@ use App\Enums\PaymentType; use App\Enums\PayrollStatus; use App\Enums\PriceType; +use App\Enums\ProductStockQuality; use App\Enums\RawMaterialUnit; use App\Enums\Role; use App\Models\Attendance; @@ -18,6 +19,7 @@ use App\Models\Expense; use App\Models\LeaveRequest; use App\Models\Order; +use App\Models\OrderItem; use App\Models\Payroll; use App\Models\ProductPrice; use App\Models\ProductVariant; @@ -254,6 +256,64 @@ public function getProductStock(): array ]; } + public function getRevenueByStockType(?string $startDate, ?string $endDate, ?User $user = null): array + { + $query = Order::where('orders.status', OrderStatus::COMPLETED); + $this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at'); + + if ($user && $this->isMarketingUser($user)) { + $query->where('orders.marketing_id', $user->id); + } + + $stockQualitySubquery = OrderItem::select('order_id') + ->selectRaw('MIN(stock_quality) as stock_quality') + ->groupBy('order_id'); + + $monthly = (clone $query) + ->toBase() + ->joinSub($stockQualitySubquery, 'oi', fn ($join) => $join->on('orders.id', '=', 'oi.order_id')) + ->selectRaw("DATE_FORMAT(orders.created_at, '%b %Y') as month") + ->selectRaw('oi.stock_quality') + ->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue') + ->groupBy(DB::raw("DATE_FORMAT(orders.created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(orders.created_at, '%b %Y')"), 'oi.stock_quality') + ->orderBy(DB::raw("DATE_FORMAT(orders.created_at, '%Y-%m')")) + ->get(); + + $allMonths = []; + $monthlyData = []; + foreach ($monthly as $row) { + $month = $row->month; + if (! array_key_exists($month, $allMonths)) { + $allMonths[$month] = $month; + $monthlyData[$month] = ['month' => $month, 'good' => 0, 'reject' => 0, 'retail' => 0]; + } + $monthlyData[$month][$row->stock_quality] = (int) $row->total_revenue; + } + + $result = []; + foreach ($allMonths as $month => $_) { + $result[] = $monthlyData[$month]; + } + + $totals = (clone $query) + ->toBase() + ->joinSub($stockQualitySubquery, 'oi', fn ($join) => $join->on('orders.id', '=', 'oi.order_id')) + ->selectRaw('oi.stock_quality') + ->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue') + ->groupBy('oi.stock_quality') + ->get() + ->keyBy('stock_quality'); + + return [ + 'monthly' => $result, + 'totals' => [ + 'good' => (int) ($totals['good']->total_revenue ?? 0), + 'reject' => (int) ($totals['reject']->total_revenue ?? 0), + 'retail' => (int) ($totals['retail']->total_revenue ?? 0), + ], + ]; + } + public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $user = null): array { $query = Order::where('orders.status', OrderStatus::COMPLETED); diff --git a/resources/js/pages/admin/analysis/index.tsx b/resources/js/pages/admin/analysis/index.tsx index 07fde7b..d779852 100644 --- a/resources/js/pages/admin/analysis/index.tsx +++ b/resources/js/pages/admin/analysis/index.tsx @@ -83,6 +83,19 @@ type AnalysisProps = { retail_stock?: number; }; }; + revenueByStockType: { + monthly: Array<{ + month: string; + good: number; + reject: number; + retail: number; + }>; + totals: { + good: number; + reject: number; + retail: number; + }; + }; revenueSummary: { total_revenue: number; total_discount: number; @@ -235,6 +248,17 @@ const revenueTrendChartConfig = (() => { const revenueTrendKeys = ['qty'] as const; +const stockComparisonConfig = (() => { + const colors = generateRandomColors(3); + return { + good: { label: 'Bagus', color: colors[0] }, + reject: { label: 'Reject', color: colors[1] }, + retail: { label: 'Ecer', color: colors[2] }, + } satisfies ChartConfig; +})(); + +const stockComparisonKeys = ['good', 'reject', 'retail'] as const; + const CHANNEL_COLORS: Record = (() => { const colors = generateRandomColors(3); return { @@ -420,6 +444,7 @@ export default function Analysis({ cashOverview, rawMaterialStock, productStock, + revenueByStockType, revenueSummary, monthlyRevenue, monthlyRevenueByChannel, @@ -441,6 +466,7 @@ export default function Analysis({ const [selectedPreset, setSelectedPreset] = useState(''); const [activeRevenueKey, setActiveRevenueKey] = useState('total'); const [activeExpenseKey, setActiveExpenseKey] = useState('total'); + const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good'); const hasActiveFilters = !!startDate || !!endDate; @@ -511,6 +537,8 @@ export default function Analysis({ ]; }, [monthlyRevenueByChannel]); + const stockComparisonData = useMemo(() => revenueByStockType.monthly, [revenueByStockType]); + const peakHour = useMemo(() => { if (busyHours.length === 0) { return { hour: '-', orders: 0 }; @@ -648,6 +676,70 @@ export default function Analysis({ )} + {can('analysis.product_stock') && ( + + +
+ Pendapatan per Jenis Stok +
+
+ {(['good', 'reject', 'retail'] as const).map((key) => { + const value = revenueByStockType.totals[key]; + return ( + + ); + })} +
+
+ + {stockComparisonData.length > 0 ? ( + + + + + ( + <> +
+ {stockComparisonConfig[activeStockKey]?.label ?? name} + + Rp{Number(value).toLocaleString('id-ID')} + + + )} + /> + } + /> + + + + ) : ( +
Belum ada data pendapatan
+ )} + + + )} + {can('analysis.revenue') && (