feat: enhance analysis functionality with new revenue metrics
- 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:
parent
1f9aba6ba5
commit
4027363729
@ -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),
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -733,14 +859,11 @@ watch([startDate, endDate], () => {
|
|||||||
]" />
|
]" />
|
||||||
|
|
||||||
<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(
|
||||||
@ -796,7 +919,8 @@ watch([startDate, endDate], () => {
|
|||||||
<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 ?? ''
|
||||||
@ -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">
|
||||||
@ -1012,9 +1215,8 @@ 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 ?? ''
|
||||||
@ -1073,9 +1275,8 @@ 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 ?? ''
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user