diff --git a/app/Http/Controllers/Admin/AnalysisController.php b/app/Http/Controllers/Admin/AnalysisController.php index 3babb08..1d2bf40 100644 --- a/app/Http/Controllers/Admin/AnalysisController.php +++ b/app/Http/Controllers/Admin/AnalysisController.php @@ -34,6 +34,8 @@ public function index(Request $request): Response 'productStock' => $this->analysisService->getProductStock(), 'revenueSummary' => $this->analysisService->getRevenueSummary($startDate, $endDate), 'monthlyRevenue' => $this->analysisService->getMonthlyRevenue($startDate, $endDate), + 'monthlyRevenueByChannel' => $this->analysisService->getMonthlyRevenueByChannel($startDate, $endDate), + 'revenueByPaymentType' => $this->analysisService->getRevenueByPaymentType($startDate, $endDate), 'expenseSummary' => $this->analysisService->getExpenseSummary($startDate, $endDate), 'monthlyExpense' => $this->analysisService->getMonthlyExpense($startDate, $endDate), 'busyHours' => $this->analysisService->getBusyHours($startDate, $endDate), diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php index 361e093..c229a1e 100644 --- a/app/Services/System/AnalysisService.php +++ b/app/Services/System/AnalysisService.php @@ -3,7 +3,9 @@ namespace App\Services\System; use App\Enums\EmployeeAdvanceStatus; +use App\Enums\OrderChannel; use App\Enums\OrderStatus; +use App\Enums\PaymentType; use App\Enums\Permission; use App\Enums\PriceType; use App\Enums\ProductStatus; @@ -398,6 +400,94 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate = return $result; } + public function getMonthlyRevenueByChannel(?Carbon $startDate = null, ?Carbon $endDate = null): array + { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + + $query = Order::query()->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)); + + if ($startDate && $endDate) { + $query->whereBetween('orders.created_at', [$startDate, $endDate]); + } + + $monthlyChannelData = $query + ->selectRaw(" + DATE_FORMAT(orders.created_at, '%Y-%m') as month_key, + channel, + SUM(total_amount) as total_revenue + ") + ->groupBy('month_key', 'channel') + ->orderBy('month_key') + ->get() + ->groupBy('month_key'); + + if ($monthlyChannelData->isEmpty()) { + return []; + } + + $start = $startDate ?? Carbon::parse($monthlyChannelData->keys()->first().'-01'); + $end = $endDate ?? Carbon::parse($monthlyChannelData->keys()->last().'-01')->endOfMonth(); + + $result = []; + $current = $start->copy()->startOfMonth(); + while ($current->lte($end)) { + $key = $current->format('Y-m'); + $monthLabel = $current->locale('id')->translatedFormat('M Y'); + + $channels = $monthlyChannelData->get($key, collect()); + $channelMap = $channels->keyBy(fn ($item) => $item->channel instanceof OrderChannel + ? $item->channel->value + : $item->channel); + + $result[] = [ + 'month' => $monthLabel, + 'store' => (int) ($channelMap->get('store')?->total_revenue ?? 0), + 'shopee' => (int) ($channelMap->get('shopee')?->total_revenue ?? 0), + 'tiktok' => (int) ($channelMap->get('tiktok')?->total_revenue ?? 0), + ]; + + $current->addMonth(); + } + + return $result; + } + + public function getRevenueByPaymentType(?Carbon $startDate = null, ?Carbon $endDate = null): array + { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + + $query = Order::query()->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)); + + if ($startDate && $endDate) { + $query->whereBetween('orders.created_at', [$startDate, $endDate]); + } + + return $query + ->selectRaw('payment_type, SUM(total_amount) as total') + ->groupBy('payment_type') + ->get() + ->map(fn ($item) => [ + 'payment_type' => $item->payment_type instanceof PaymentType + ? $item->payment_type->value + : $item->payment_type, + 'label' => $item->payment_type instanceof PaymentType + ? $item->payment_type->label() + : PaymentType::from($item->payment_type)->label(), + 'total' => (int) $item->total, + ]) + ->toArray(); + } + public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array { /** @var User|null $user */ diff --git a/resources/js/pages/admin/Analysis.vue b/resources/js/pages/admin/Analysis.vue index 2f8b551..aeeef09 100644 --- a/resources/js/pages/admin/Analysis.vue +++ b/resources/js/pages/admin/Analysis.vue @@ -7,10 +7,12 @@ import { TrendingUp, UserCheck, } from '@lucide/vue'; -import { GroupedBar } from '@unovis/ts'; +import { Donut, GroupedBar } from '@unovis/ts'; import { VisAxis, + VisDonut, VisGroupedBar, + VisSingleContainer, VisTooltip, VisXYContainer, } from '@unovis/vue'; @@ -102,6 +104,17 @@ const props = defineProps<{ net_retail: number; deduction: number; }>; + monthlyRevenueByChannel: Array<{ + month: string; + store: number; + shopee: number; + tiktok: number; + }>; + revenueByPaymentType: Array<{ + payment_type: string; + label: string; + total: number; + }>; expenseSummary: { total: number; purchase_total: number; @@ -232,6 +245,7 @@ const revenueChartConfig = { const revenueTotals = computed(() => { const net_warehouse = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_warehouse ?? 0), 0); const net_retail = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_retail ?? 0), 0); + return { total: props.revenueSummary.total_revenue, net: @@ -267,6 +281,106 @@ const revenueColors = computed(() => { return visibleRevenueCharts.value.map((chart) => revenueChartConfig[chart].color); }); +// Revenue By Channel Chart +const channelChartColors: Record = { + store: '#22c55e', + shopee: '#ee4d2d', + tiktok: '#000000', +}; + +const channelChartLabels: Record = { + store: 'Toko', + shopee: 'Shopee', + tiktok: 'TikTok', +}; + +const revenueByChannelData = computed(() => { + const totals = props.monthlyRevenueByChannel.reduce( + (acc, item) => { + acc.store += item.store ?? 0; + acc.shopee += item.shopee ?? 0; + acc.tiktok += item.tiktok ?? 0; + + return acc; + }, + { store: 0, shopee: 0, tiktok: 0 }, + ); + + return [ + { channel: 'store', label: channelChartLabels.store, total: totals.store }, + { channel: 'shopee', label: channelChartLabels.shopee, total: totals.shopee }, + { channel: 'tiktok', label: channelChartLabels.tiktok, total: totals.tiktok }, + ]; +}); + +const revenueByChannelChartConfig = computed(() => { + const config: ChartConfig = {}; + revenueByChannelData.value.forEach((item) => { + config[item.channel] = { + label: item.label, + color: channelChartColors[item.channel], + }; + }); + + return config; +}); + +const channelSelector = Donut.selectors.segment; + +const channelRevenueTotal = computed(() => { + return revenueByChannelData.value.reduce((sum, item) => sum + (item.total ?? 0), 0); +}); + +function revenueByChannelTooltip(arc: any) { + const d = arc.data as typeof revenueByChannelData.value[number]; + + return `
+

+ + ${d.label} + Rp${formatRupiah(d.total)} +

+
`; +} + +// Revenue By Payment Type Chart +const paymentTypeColors: Record = { + cash: '#22c55e', + transfer: '#60a5fa', + qris: '#a855f7', + marketplace: '#f97316', +}; + +const paymentTypeChartConfig = computed(() => { + const config: ChartConfig = {}; + props.revenueByPaymentType.forEach((item) => { + config[item.payment_type] = { + label: item.label, + color: paymentTypeColors[item.payment_type] ?? '#94a3b8', + }; + }); + + return config; +}); + +const paymentTypeSelector = Donut.selectors.segment; + +const paymentTypeTotal = computed(() => { + return props.revenueByPaymentType.reduce((sum, item) => sum + (item.total ?? 0), 0); +}); + +function paymentTypeTooltip(arc: any) { + const d = arc.data as typeof props.revenueByPaymentType[number]; + + return `
+

+ + ${d.label} + Rp${formatRupiah(d.total)} +

+
`; +} + // Expense Chart type MonthlyExpenseData = { month: string; @@ -370,15 +484,24 @@ const peakHour = computed(() => { const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024); onMounted(() => { - const onResize = () => { windowWidth.value = window.innerWidth; }; + const onResize = () => { + windowWidth.value = window.innerWidth; + }; window.addEventListener('resize', onResize); onBeforeUnmount(() => window.removeEventListener('resize', onResize)); }); const busyHourTickValues = computed(() => { const all = props.busyHours.map((_, i) => i); - if (windowWidth.value < 640) return all.filter((_, i) => i % 4 === 0); - if (windowWidth.value < 1024) return all.filter((_, i) => i % 2 === 0); + + if (windowWidth.value < 640) { + return all.filter((_, i) => i % 4 === 0); + } + + if (windowWidth.value < 1024) { + return all.filter((_, i) => i % 2 === 0); + } + return all; }); @@ -387,14 +510,15 @@ const sectionOrder = computed(() => { return { statCards: 1, revenue: 5, - expense: 6, - profitGross: 7, - totalOrder: 8, - marketingSales: 9, - topSuppliers: 10, - topProducts: 11, - topCustomers: 12, - busyHours: 13, + revenueByChannel: 6, + expense: 7, + profitGross: 8, + totalOrder: 9, + marketingSales: 10, + topSuppliers: 11, + topProducts: 12, + topCustomers: 13, + busyHours: 14, }; } @@ -402,13 +526,14 @@ const sectionOrder = computed(() => { return { statCards: 1, revenue: 4, - expense: 5, - profitGross: 6, - totalOrder: 7, - marketingSales: 8, - topProducts: 9, - topCustomers: 10, - busyHours: 11, + revenueByChannel: 5, + expense: 6, + profitGross: 7, + totalOrder: 8, + marketingSales: 9, + topProducts: 10, + topCustomers: 11, + busyHours: 12, }; } @@ -416,9 +541,10 @@ const sectionOrder = computed(() => { return { statCards: 1, revenue: 2, - totalOrder: 3, - topProducts: 4, - topCustomers: 5, + revenueByChannel: 3, + totalOrder: 4, + topProducts: 5, + topCustomers: 6, }; } @@ -692,55 +818,52 @@ watch([startDate, endDate], () => { + { + label: 'Deposit', + value: + 'Rp' + formatRupiah(cashOverview.total_deposit), + }, + { + label: 'Withdrawal', + value: + 'Rp' + + formatRupiah(cashOverview.total_withdrawal), + }, + ]" :cols="2" /> + " :items="[ + { + label: 'Yard', + value: + rawMaterialStock.by_unit?.yard?.toLocaleString( + 'id-ID', + ) ?? '0', + }, + { + label: 'Meter', + value: + rawMaterialStock.by_unit?.meter?.toLocaleString( + 'id-ID', + ) ?? '0', + }, + { + label: 'Kg', + value: + rawMaterialStock.by_unit?.kilogram?.toLocaleString( + 'id-ID', + ) ?? '0', + }, + ]" /> {{ revenueChartConfig[chart].label - }} + }} + " :y="revenueYAccessors" :color="revenueColors" :bar-padding="0.1" + :group-padding="0.2" :rounded-corners="4" /> + " /> + + + +
+ Pendapatan per Channel +
+
+
+ + {{ item.label }} + + + Rp{{ formatRupiah(item.total) }} + +
+
+
+ +
+
+ + + + + + +
+
+
+ Belum ada data pendapatan +
+
+
+ + + + +
+ Pendapatan per Pembayaran +
+
+
+ + {{ item.label }} + + + Rp{{ formatRupiah(item.total) }} + +
+
+
+ +
+
+ + + + + + +
+
+
+ Belum ada data pendapatan +
+
+
+ + @@ -845,7 +1048,7 @@ watch([startDate, endDate], () => { }"> {{ expenseChartConfig[chart].label - }} + }} @@ -855,11 +1058,11 @@ watch([startDate, endDate], () => { :group-padding="0.2" :rounded-corners="4" /> + " /> + { + label: 'HPP', + value: 'Rp' + formatRupiah(profitMetrics.hpp), + }, + ...(!hasRole('cashier') ? [ + { + label: 'Laba Bersih', + value: + 'Rp' + + formatRupiah(profitMetrics.net_profit) + + ' (' + + profitMetrics.profit_margin + + '%)', + }, + ] : []), + ]" :style="{ order: sectionOrder.profitGross ?? 99 }" /> + { + label: 'Produk Terjual', + value: profitMetrics.total_products_sold.toLocaleString( + 'id-ID', + ), + }, + { + label: 'Item/Transaksi', + value: profitMetrics.items_per_transaction, + }, + { + label: 'Rata-rata/Transaksi', + value: 'Rp' + formatRupiah(profitMetrics.aov), + }, + ]" :style="{ order: sectionOrder.totalOrder ?? 99 }" /> @@ -1012,16 +1215,15 @@ watch([startDate, endDate], () => { - + + " /> + " /> 0" :config="customerChartConfig" class="aspect-auto h-[250px] w-full"> - + + " />