feat: enhance analysis functionality with new revenue metrics
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

- Added methods to AnalysisService for retrieving monthly revenue by channel and revenue by payment type.
- Updated AnalysisController to include new metrics in the index response.
- Enhanced Analysis.vue to visualize revenue data by channel and payment type with appropriate charts and tooltips.
This commit is contained in:
Yoga Pangestu 2026-07-29 09:36:22 +07:00
parent 1f9aba6ba5
commit 4027363729
3 changed files with 412 additions and 119 deletions

View File

@ -34,6 +34,8 @@ public function index(Request $request): Response
'productStock' => $this->analysisService->getProductStock(), 'productStock' => $this->analysisService->getProductStock(),
'revenueSummary' => $this->analysisService->getRevenueSummary($startDate, $endDate), 'revenueSummary' => $this->analysisService->getRevenueSummary($startDate, $endDate),
'monthlyRevenue' => $this->analysisService->getMonthlyRevenue($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), 'expenseSummary' => $this->analysisService->getExpenseSummary($startDate, $endDate),
'monthlyExpense' => $this->analysisService->getMonthlyExpense($startDate, $endDate), 'monthlyExpense' => $this->analysisService->getMonthlyExpense($startDate, $endDate),
'busyHours' => $this->analysisService->getBusyHours($startDate, $endDate), 'busyHours' => $this->analysisService->getBusyHours($startDate, $endDate),

View File

@ -3,7 +3,9 @@
namespace App\Services\System; namespace App\Services\System;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\Permission; use App\Enums\Permission;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStatus; use App\Enums\ProductStatus;
@ -398,6 +400,94 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
return $result; 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 public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{ {
/** @var User|null $user */ /** @var User|null $user */

View File

@ -7,10 +7,12 @@ import {
TrendingUp, TrendingUp,
UserCheck, UserCheck,
} from '@lucide/vue'; } from '@lucide/vue';
import { GroupedBar } from '@unovis/ts'; import { Donut, GroupedBar } from '@unovis/ts';
import { import {
VisAxis, VisAxis,
VisDonut,
VisGroupedBar, VisGroupedBar,
VisSingleContainer,
VisTooltip, VisTooltip,
VisXYContainer, VisXYContainer,
} from '@unovis/vue'; } from '@unovis/vue';
@ -102,6 +104,17 @@ const props = defineProps<{
net_retail: number; net_retail: number;
deduction: number; deduction: number;
}>; }>;
monthlyRevenueByChannel: Array<{
month: string;
store: number;
shopee: number;
tiktok: number;
}>;
revenueByPaymentType: Array<{
payment_type: string;
label: string;
total: number;
}>;
expenseSummary: { expenseSummary: {
total: number; total: number;
purchase_total: number; purchase_total: number;
@ -232,6 +245,7 @@ const revenueChartConfig = {
const revenueTotals = computed(() => { const revenueTotals = computed(() => {
const net_warehouse = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_warehouse ?? 0), 0); 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); const net_retail = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_retail ?? 0), 0);
return { return {
total: props.revenueSummary.total_revenue, total: props.revenueSummary.total_revenue,
net: net:
@ -267,6 +281,106 @@ const revenueColors = computed(() => {
return visibleRevenueCharts.value.map((chart) => revenueChartConfig[chart].color); return visibleRevenueCharts.value.map((chart) => revenueChartConfig[chart].color);
}); });
// Revenue By Channel Chart
const channelChartColors: Record<string, string> = {
store: '#22c55e',
shopee: '#ee4d2d',
tiktok: '#000000',
};
const channelChartLabels: Record<string, string> = {
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 `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${channelChartColors[d.channel] ?? '#94a3b8'}"></span>
<span class="text-muted-foreground">${d.label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(d.total)}</span>
</p>
</div>`;
}
// Revenue By Payment Type Chart
const paymentTypeColors: Record<string, string> = {
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 `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${paymentTypeColors[d.payment_type] ?? '#94a3b8'}"></span>
<span class="text-muted-foreground">${d.label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(d.total)}</span>
</p>
</div>`;
}
// Expense Chart // Expense Chart
type MonthlyExpenseData = { type MonthlyExpenseData = {
month: string; month: string;
@ -370,15 +484,24 @@ const peakHour = computed(() => {
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024); const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024);
onMounted(() => { onMounted(() => {
const onResize = () => { windowWidth.value = window.innerWidth; }; const onResize = () => {
windowWidth.value = window.innerWidth;
};
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
onBeforeUnmount(() => window.removeEventListener('resize', onResize)); onBeforeUnmount(() => window.removeEventListener('resize', onResize));
}); });
const busyHourTickValues = computed(() => { const busyHourTickValues = computed(() => {
const all = props.busyHours.map((_, i) => i); 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; return all;
}); });
@ -387,14 +510,15 @@ const sectionOrder = computed(() => {
return { return {
statCards: 1, statCards: 1,
revenue: 5, revenue: 5,
expense: 6, revenueByChannel: 6,
profitGross: 7, expense: 7,
totalOrder: 8, profitGross: 8,
marketingSales: 9, totalOrder: 9,
topSuppliers: 10, marketingSales: 10,
topProducts: 11, topSuppliers: 11,
topCustomers: 12, topProducts: 12,
busyHours: 13, topCustomers: 13,
busyHours: 14,
}; };
} }
@ -402,13 +526,14 @@ const sectionOrder = computed(() => {
return { return {
statCards: 1, statCards: 1,
revenue: 4, revenue: 4,
expense: 5, revenueByChannel: 5,
profitGross: 6, expense: 6,
totalOrder: 7, profitGross: 7,
marketingSales: 8, totalOrder: 8,
topProducts: 9, marketingSales: 9,
topCustomers: 10, topProducts: 10,
busyHours: 11, topCustomers: 11,
busyHours: 12,
}; };
} }
@ -416,9 +541,10 @@ const sectionOrder = computed(() => {
return { return {
statCards: 1, statCards: 1,
revenue: 2, revenue: 2,
totalOrder: 3, revenueByChannel: 3,
topProducts: 4, totalOrder: 4,
topCustomers: 5, topProducts: 5,
topCustomers: 6,
}; };
} }
@ -692,55 +818,52 @@ watch([startDate, endDate], () => {
<StatCard v-if="can('analysis.cash')" title="Kas Toko" :icon="Banknote" main-label="Total Saldo" <StatCard v-if="can('analysis.cash')" title="Kas Toko" :icon="Banknote" main-label="Total Saldo"
:main-value="'Rp' + formatRupiah(cashOverview.total_balance) :main-value="'Rp' + formatRupiah(cashOverview.total_balance)
" :sub-label="cashOverview.total_transactions + ' transaksi'" :items="[ " :sub-label="cashOverview.total_transactions + ' transaksi'" :items="[
{ {
label: 'Deposit', label: 'Deposit',
value: value:
'Rp' + formatRupiah(cashOverview.total_deposit), 'Rp' + formatRupiah(cashOverview.total_deposit),
}, },
{ {
label: 'Withdrawal', label: 'Withdrawal',
value: value:
'Rp' + 'Rp' +
formatRupiah(cashOverview.total_withdrawal), formatRupiah(cashOverview.total_withdrawal),
}, },
]" :cols="2" /> ]" :cols="2" />
<StatCard v-if="can('analysis.raw_materials')" title="Bahan Baku" :icon="Package" <StatCard v-if="can('analysis.raw_materials')" title="Bahan Baku" :icon="Package"
main-label="Total Stok" :main-value="rawMaterialStock.total_stock.toLocaleString('id-ID') main-label="Total Stok" :main-value="rawMaterialStock.total_stock.toLocaleString('id-ID')
" :sub-label="'Rp' + formatRupiah(rawMaterialStock.total_value) " :sub-label="'Rp' + formatRupiah(rawMaterialStock.total_value)
" :items="[ " :items="[
{ {
label: 'Yard', label: 'Yard',
value: value:
rawMaterialStock.by_unit?.yard?.toLocaleString( rawMaterialStock.by_unit?.yard?.toLocaleString(
'id-ID', 'id-ID',
) ?? '0', ) ?? '0',
}, },
{ {
label: 'Meter', label: 'Meter',
value: value:
rawMaterialStock.by_unit?.meter?.toLocaleString( rawMaterialStock.by_unit?.meter?.toLocaleString(
'id-ID', 'id-ID',
) ?? '0', ) ?? '0',
}, },
{ {
label: 'Kg', label: 'Kg',
value: value:
rawMaterialStock.by_unit?.kilogram?.toLocaleString( rawMaterialStock.by_unit?.kilogram?.toLocaleString(
'id-ID', 'id-ID',
) ?? '0', ) ?? '0',
}, },
]" /> ]" />
<StatCard v-if="can('analysis.product_stock')" title="Stok Produk" :icon="ShoppingCart" <StatCard v-if="can('analysis.product_stock')" title="Stok Produk" :icon="ShoppingCart"
main-label="Total Stok" main-label="Total Stok" :main-value="(
:main-value="(
productStock.total_stock + productStock.total_stock +
productStock.total_reject + productStock.total_reject +
productStock.total_retail productStock.total_retail
).toLocaleString('id-ID')" ).toLocaleString('id-ID')" :sub-label="'Rp' + formatRupiah(productStock.total_value)" :items="[
:sub-label="'Rp' + formatRupiah(productStock.total_value)"
:items="[
{ {
label: 'Stok Bagus', label: 'Stok Bagus',
value: productStock.total_stock.toLocaleString( value: productStock.total_stock.toLocaleString(
@ -790,20 +913,21 @@ watch([startDate, endDate], () => {
}"></span> }"></span>
<span class="text-xs text-muted-foreground">{{ <span class="text-xs text-muted-foreground">{{
revenueChartConfig[chart].label revenueChartConfig[chart].label
}}</span> }}</span>
</div> </div>
</div> </div>
<ChartContainer :config="revenueChartConfig" class="aspect-auto h-[300px] w-full"> <ChartContainer :config="revenueChartConfig" class="aspect-auto h-[300px] w-full">
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]"> <VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i <VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i
" :y="revenueYAccessors" :color="revenueColors" :bar-padding="0.1" :group-padding="0.2" :rounded-corners="4" /> " :y="revenueYAccessors" :color="revenueColors" :bar-padding="0.1"
:group-padding="0.2" :rounded-corners="4" />
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i <VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) => " :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
monthlyRevenue[d]?.month ?? '' monthlyRevenue[d]?.month ?? ''
" :tick-values="monthlyRevenue.map((_, i) => i) " :tick-values="monthlyRevenue.map((_, i) => i)
" /> " />
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) => <VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
'Rp' + formatRupiahShort(d) 'Rp' + formatRupiahShort(d)
" /> " />
<VisTooltip :triggers="{ <VisTooltip :triggers="{
[barSelector]: revenueTooltip, [barSelector]: revenueTooltip,
@ -817,6 +941,85 @@ watch([startDate, endDate], () => {
</CardContent> </CardContent>
</Card> </Card>
<!-- Revenue By Channel & Payment Type -->
<div class="grid gap-4 md:grid-cols-2" :style="{ order: sectionOrder.revenueByChannel ?? 99 }">
<!-- Revenue By Channel Chart -->
<Card v-if="can('analysis.revenue')" class="py-4 sm:py-0">
<CardHeader class="flex flex-row items-center justify-between border-b p-0!">
<div class="flex flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Pendapatan per Channel</CardTitle>
</div>
<div class="flex flex-wrap">
<div v-for="item in revenueByChannelData" :key="item.channel"
class="flex flex-col justify-center gap-1 border-l px-4 py-3 text-right sm:px-6 sm:py-4">
<span class="text-xs text-muted-foreground">
{{ item.label }}
</span>
<span class="text-sm">
Rp{{ formatRupiah(item.total) }}
</span>
</div>
</div>
</CardHeader>
<CardContent class="px-2 sm:p-6">
<div v-if="revenueByChannelData.length > 0">
<div class="mx-auto aspect-square max-h-[300px]">
<ChartContainer :config="revenueByChannelChartConfig" class="h-full w-full">
<VisSingleContainer :data="revenueByChannelData" :margin="{ top: 10, bottom: 10 }">
<VisDonut :value="(d: (typeof revenueByChannelData)[number]) => d.total"
:color="(d: (typeof revenueByChannelData)[number]) => channelChartColors[d.channel] ?? '#94a3b8'"
:arc-width="30" />
<VisTooltip :triggers="{ [channelSelector]: revenueByChannelTooltip }"
class-name="custom-tooltip" />
</VisSingleContainer>
</ChartContainer>
</div>
</div>
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
Belum ada data pendapatan
</div>
</CardContent>
</Card>
<!-- Revenue By Payment Type Chart -->
<Card v-if="can('analysis.revenue')" class="py-4 sm:py-0">
<CardHeader class="flex flex-row items-center justify-between border-b p-0!">
<div class="flex flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Pendapatan per Pembayaran</CardTitle>
</div>
<div class="flex flex-wrap">
<div v-for="item in revenueByPaymentType" :key="item.payment_type"
class="flex flex-col justify-center gap-1 border-l px-4 py-3 text-right sm:px-6 sm:py-4">
<span class="text-xs text-muted-foreground">
{{ item.label }}
</span>
<span class="text-sm">
Rp{{ formatRupiah(item.total) }}
</span>
</div>
</div>
</CardHeader>
<CardContent class="px-2 sm:p-6">
<div v-if="revenueByPaymentType.length > 0">
<div class="mx-auto aspect-square max-h-[300px]">
<ChartContainer :config="paymentTypeChartConfig" class="h-full w-full">
<VisSingleContainer :data="revenueByPaymentType" :margin="{ top: 10, bottom: 10 }">
<VisDonut :value="(d: (typeof revenueByPaymentType)[number]) => d.total"
:color="(d: (typeof revenueByPaymentType)[number]) => paymentTypeColors[d.payment_type] ?? '#94a3b8'"
:arc-width="30" />
<VisTooltip :triggers="{ [paymentTypeSelector]: paymentTypeTooltip }"
class-name="custom-tooltip" />
</VisSingleContainer>
</ChartContainer>
</div>
</div>
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
Belum ada data pendapatan
</div>
</CardContent>
</Card>
</div>
<!-- Expense Chart --> <!-- Expense Chart -->
<Card v-if="can('analysis.expense')" class="py-4 sm:py-0" :style="{ order: sectionOrder.expense ?? 99 }"> <Card v-if="can('analysis.expense')" class="py-4 sm:py-0" :style="{ order: sectionOrder.expense ?? 99 }">
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row"> <CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
@ -845,7 +1048,7 @@ watch([startDate, endDate], () => {
}"></span> }"></span>
<span class="text-xs text-muted-foreground">{{ <span class="text-xs text-muted-foreground">{{
expenseChartConfig[chart].label expenseChartConfig[chart].label
}}</span> }}</span>
</div> </div>
</div> </div>
<ChartContainer :config="expenseChartConfig" class="aspect-auto h-[300px] w-full"> <ChartContainer :config="expenseChartConfig" class="aspect-auto h-[300px] w-full">
@ -855,11 +1058,11 @@ watch([startDate, endDate], () => {
:group-padding="0.2" :rounded-corners="4" /> :group-padding="0.2" :rounded-corners="4" />
<VisAxis type="x" :x="(_d: MonthlyExpenseData, i: number) => i <VisAxis type="x" :x="(_d: MonthlyExpenseData, i: number) => i
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) => " :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
monthlyExpense[d]?.month ?? '' monthlyExpense[d]?.month ?? ''
" :tick-values="monthlyExpense.map((_, i) => i) " :tick-values="monthlyExpense.map((_, i) => i)
" /> " />
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) => <VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
'Rp' + formatRupiahShort(d) 'Rp' + formatRupiahShort(d)
" /> " />
<VisTooltip :triggers="{ <VisTooltip :triggers="{
[barSelector]: expenseTooltip, [barSelector]: expenseTooltip,
@ -878,49 +1081,49 @@ watch([startDate, endDate], () => {
can('analysis.profit_gross') || can('analysis.profit_gross') ||
can('analysis.profit_hpp') can('analysis.profit_hpp')
" title="Laba Kotor" :icon="TrendingUp" main-label="Laba Kotor" :main-value="'Rp' + formatRupiah(profitMetrics.gross_profit) " title="Laba Kotor" :icon="TrendingUp" main-label="Laba Kotor" :main-value="'Rp' + formatRupiah(profitMetrics.gross_profit)
" :items="[ " :items="[
{
label: 'Pendapatan',
value:
'Rp' +
formatRupiah(revenueSummary.total_revenue),
},
{
label: 'HPP',
value: 'Rp' + formatRupiah(profitMetrics.hpp),
},
...(!hasRole('cashier') ? [
{ {
label: 'Laba Bersih', label: 'Pendapatan',
value: value:
'Rp' + 'Rp' +
formatRupiah(profitMetrics.net_profit) + formatRupiah(revenueSummary.total_revenue),
' (' +
profitMetrics.profit_margin +
'%)',
}, },
] : []), {
]" :style="{ order: sectionOrder.profitGross ?? 99 }" /> 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 }" />
<!-- Total Order --> <!-- Total Order -->
<StatCard v-if="can('analysis.profit_orders')" title="Total Order" :icon="ShoppingCart" <StatCard v-if="can('analysis.profit_orders')" title="Total Order" :icon="ShoppingCart"
main-label="Pesanan Selesai" :main-value="profitMetrics.total_orders.toLocaleString('id-ID') main-label="Pesanan Selesai" :main-value="profitMetrics.total_orders.toLocaleString('id-ID')
" :items="[ " :items="[
{ {
label: 'Produk Terjual', label: 'Produk Terjual',
value: profitMetrics.total_products_sold.toLocaleString( value: profitMetrics.total_products_sold.toLocaleString(
'id-ID', 'id-ID',
), ),
}, },
{ {
label: 'Item/Transaksi', label: 'Item/Transaksi',
value: profitMetrics.items_per_transaction, value: profitMetrics.items_per_transaction,
}, },
{ {
label: 'Rata-rata/Transaksi', label: 'Rata-rata/Transaksi',
value: 'Rp' + formatRupiah(profitMetrics.aov), value: 'Rp' + formatRupiah(profitMetrics.aov),
}, },
]" :style="{ order: sectionOrder.totalOrder ?? 99 }" /> ]" :style="{ order: sectionOrder.totalOrder ?? 99 }" />
<!-- Marketing Sales Table --> <!-- Marketing Sales Table -->
<Card v-if="can('analysis.marketing_sales')" :style="{ order: sectionOrder.marketingSales ?? 99 }"> <Card v-if="can('analysis.marketing_sales')" :style="{ order: sectionOrder.marketingSales ?? 99 }">
@ -1012,16 +1215,15 @@ watch([startDate, endDate], () => {
<ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig" <ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig"
class="aspect-auto h-[250px] w-full"> class="aspect-auto h-[250px] w-full">
<VisXYContainer :data="supplierBarData" :y-domain="[0, undefined]"> <VisXYContainer :data="supplierBarData" :y-domain="[0, undefined]">
<VisGroupedBar :x="(_d: SupplierData, i: number) => i" <VisGroupedBar :x="(_d: SupplierData, i: number) => i" :y="(d: SupplierData) => d.amount"
:y="(d: SupplierData) => d.amount" :color="supplierChartConfig.amount.color" :color="supplierChartConfig.amount.color" :rounded-corners="4" bar-padding="0.1" />
:rounded-corners="4" bar-padding="0.1" />
<VisAxis type="x" :x="(_d: SupplierData, i: number) => i" :tick-line="false" <VisAxis type="x" :x="(_d: SupplierData, i: number) => i" :tick-line="false"
:domain-line="false" :grid-line="false" :tick-format="(d: number) => :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
supplierBarData[d]?.name ?? '' supplierBarData[d]?.name ?? ''
" :tick-values="supplierBarData.map((_, i) => i) " :tick-values="supplierBarData.map((_, i) => i)
" /> " />
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" :tick-format="(d: number) => <VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
'Rp' + formatRupiahShort(d) 'Rp' + formatRupiahShort(d)
" /> " />
<VisTooltip :triggers="{ <VisTooltip :triggers="{
[barSelector]: supplierTooltip, [barSelector]: supplierTooltip,
@ -1048,9 +1250,9 @@ watch([startDate, endDate], () => {
:color="productChartConfig.qty.color" :rounded-corners="4" bar-padding="0.1" /> :color="productChartConfig.qty.color" :rounded-corners="4" bar-padding="0.1" />
<VisAxis type="x" :x="(_d: ProductData, i: number) => i" :tick-line="false" <VisAxis type="x" :x="(_d: ProductData, i: number) => i" :tick-line="false"
:domain-line="false" :grid-line="false" :tick-format="(d: number) => :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
productBarData[d]?.name ?? '' productBarData[d]?.name ?? ''
" :tick-values="productBarData.map((_, i) => i) " :tick-values="productBarData.map((_, i) => i)
" /> " />
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" /> <VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" />
<VisTooltip :triggers="{ <VisTooltip :triggers="{
[barSelector]: productTooltip, [barSelector]: productTooltip,
@ -1073,16 +1275,15 @@ watch([startDate, endDate], () => {
<ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig" <ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig"
class="aspect-auto h-[250px] w-full"> class="aspect-auto h-[250px] w-full">
<VisXYContainer :data="customerBarData" :y-domain="[0, undefined]"> <VisXYContainer :data="customerBarData" :y-domain="[0, undefined]">
<VisGroupedBar :x="(_d: CustomerData, i: number) => i" <VisGroupedBar :x="(_d: CustomerData, i: number) => i" :y="(d: CustomerData) => d.amount"
:y="(d: CustomerData) => d.amount" :color="customerChartConfig.amount.color" :color="customerChartConfig.amount.color" :rounded-corners="4" bar-padding="0.1" />
:rounded-corners="4" bar-padding="0.1" />
<VisAxis type="x" :x="(_d: CustomerData, i: number) => i" :tick-line="false" <VisAxis type="x" :x="(_d: CustomerData, i: number) => i" :tick-line="false"
:domain-line="false" :grid-line="false" :tick-format="(d: number) => :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
customerBarData[d]?.name ?? '' customerBarData[d]?.name ?? ''
" :tick-values="customerBarData.map((_, i) => i) " :tick-values="customerBarData.map((_, i) => i)
" /> " />
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" :tick-format="(d: number) => <VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
'Rp' + formatRupiahShort(d) 'Rp' + formatRupiahShort(d)
" /> " />
<VisTooltip :triggers="{ <VisTooltip :triggers="{
[barSelector]: customerTooltip, [barSelector]: customerTooltip,