673 lines
28 KiB
Vue
673 lines
28 KiB
Vue
<script setup lang="ts">
|
|
import { Head, router } from '@inertiajs/vue3';
|
|
import { Banknote, Package, Percent, ShoppingCart, TrendingDown, TrendingUp, UserCheck } from '@lucide/vue';
|
|
import { VisAxis, VisGroupedBar, VisXYContainer } from '@unovis/vue';
|
|
import { computed, ref, watch } from 'vue';
|
|
import StatCard from '@/components/card/StatCard.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from '@/components/ui/card';
|
|
import type { ChartConfig } from '@/components/ui/chart';
|
|
import { ChartContainer } from '@/components/ui/chart';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import { formatRupiah } from '@/lib/rupiah';
|
|
import admin from '@/routes/admin';
|
|
|
|
const props = defineProps<{
|
|
filters: {
|
|
start_date?: string;
|
|
end_date?: string;
|
|
};
|
|
attendance: {
|
|
total_employees: number;
|
|
percentage: number;
|
|
present: number;
|
|
absent: number;
|
|
on_leave: number;
|
|
};
|
|
cashOverview: {
|
|
total_balance: number;
|
|
total_transactions: number;
|
|
total_deposit: number;
|
|
total_withdrawal: number;
|
|
};
|
|
rawMaterialStock: {
|
|
total_stock: number;
|
|
total_value: number;
|
|
by_unit: {
|
|
yard: number;
|
|
meter: number;
|
|
kilogram: number;
|
|
};
|
|
};
|
|
productStock: {
|
|
total_stock: number;
|
|
total_reject: number;
|
|
total_value: number;
|
|
total_products: number;
|
|
total_variants: number;
|
|
total_categories: number;
|
|
};
|
|
revenueSummary: {
|
|
total_revenue: number;
|
|
total_discount: number;
|
|
total_marketplace_fees: number;
|
|
total_potongan: number;
|
|
total_shipping: number;
|
|
total_orders: number;
|
|
avg_order: number;
|
|
};
|
|
monthlyRevenue: Array<{
|
|
month: string;
|
|
total: number;
|
|
net: number;
|
|
potongan: number;
|
|
ongkir: number;
|
|
}>;
|
|
expenseSummary: {
|
|
total: number;
|
|
purchase_total: number;
|
|
expense_total: number;
|
|
advance_total: number;
|
|
};
|
|
monthlyExpense: Array<{
|
|
month: string;
|
|
total: number;
|
|
belanja: number;
|
|
pengeluaran: number;
|
|
kasbon: number;
|
|
}>;
|
|
busyHours: Array<{
|
|
hour: string;
|
|
orders: number;
|
|
}>;
|
|
profitMetrics: {
|
|
total_orders: number;
|
|
total_products_sold: number;
|
|
hpp: number;
|
|
laba_kotor: number;
|
|
laba_bersih: number;
|
|
profit_margin: number;
|
|
aov: number;
|
|
items_per_transaction: number;
|
|
};
|
|
topSuppliers: Array<{
|
|
name: string;
|
|
total_amount: number;
|
|
purchase_count: number;
|
|
}>;
|
|
topCustomers: Array<{
|
|
name: string;
|
|
total_amount: number;
|
|
order_count: number;
|
|
}>;
|
|
topProducts: Array<{
|
|
name: string;
|
|
total_qty: number;
|
|
total_revenue: number;
|
|
}>;
|
|
}>();
|
|
|
|
const startDate = ref(props.filters.start_date ?? '');
|
|
const endDate = ref(props.filters.end_date ?? '');
|
|
const selectedPreset = ref<string>('');
|
|
|
|
const hasActiveFilters = computed(() => !!startDate.value || !!endDate.value);
|
|
|
|
function formatDate(date: Date): string {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function onPresetChange(value: any) {
|
|
if (typeof value !== 'string') {
|
|
return;
|
|
}
|
|
|
|
selectedPreset.value = value;
|
|
const now = new Date();
|
|
let start: Date;
|
|
|
|
switch (value) {
|
|
case 'today':
|
|
start = new Date(now);
|
|
break;
|
|
case 'week':
|
|
start = new Date(now);
|
|
start.setDate(now.getDate() - now.getDay() + 1);
|
|
break;
|
|
case 'month':
|
|
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
break;
|
|
case 'year':
|
|
start = new Date(now.getFullYear(), 0, 1);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
startDate.value = formatDate(start);
|
|
endDate.value = formatDate(now);
|
|
}
|
|
|
|
// Revenue Chart
|
|
type MonthlyRevenueData = { month: string; total: number; net: number; potongan: number; ongkir: number };
|
|
|
|
const revenueChartConfig = {
|
|
total: {
|
|
label: 'Total',
|
|
color: 'var(--chart-1)',
|
|
},
|
|
net: {
|
|
label: 'Bersih',
|
|
color: 'var(--chart-2)',
|
|
},
|
|
potongan: {
|
|
label: 'Potongan',
|
|
color: 'var(--chart-3)',
|
|
},
|
|
ongkir: {
|
|
label: 'Ongkir',
|
|
color: 'var(--chart-4)',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const activeRevenueChart = ref<'total' | 'net' | 'potongan' | 'ongkir'>('total');
|
|
|
|
const revenueTotals = computed(() => ({
|
|
total: props.revenueSummary.total_revenue,
|
|
net: props.revenueSummary.total_revenue - props.revenueSummary.total_potongan,
|
|
potongan: props.revenueSummary.total_potongan,
|
|
ongkir: props.revenueSummary.total_shipping,
|
|
}));
|
|
|
|
// Expense Chart
|
|
type MonthlyExpenseData = { month: string; total: number; belanja: number; pengeluaran: number; kasbon: number };
|
|
|
|
const expenseChartConfig = {
|
|
total: {
|
|
label: 'Total',
|
|
color: 'var(--chart-1)',
|
|
},
|
|
belanja: {
|
|
label: 'Belanja',
|
|
color: 'var(--chart-2)',
|
|
},
|
|
pengeluaran: {
|
|
label: 'Pengeluaran',
|
|
color: 'var(--chart-3)',
|
|
},
|
|
kasbon: {
|
|
label: 'Kasbon',
|
|
color: 'var(--chart-4)',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const activeExpenseChart = ref<'total' | 'belanja' | 'pengeluaran' | 'kasbon'>('total');
|
|
|
|
const expenseTotals = computed(() => ({
|
|
total: props.expenseSummary.total,
|
|
belanja: props.expenseSummary.purchase_total,
|
|
pengeluaran: props.expenseSummary.expense_total,
|
|
kasbon: props.expenseSummary.advance_total,
|
|
}));
|
|
|
|
// Busy Hours Chart
|
|
type BusyHourData = { hour: string; orders: number };
|
|
|
|
const busyHoursChartConfig = {
|
|
orders: {
|
|
label: 'Pesanan',
|
|
color: 'var(--chart-1)',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const peakHour = computed(() => {
|
|
if (props.busyHours.length === 0) {
|
|
return { hour: '-', orders: 0 };
|
|
}
|
|
|
|
return props.busyHours.reduce((max, item) => item.orders > max.orders ? item : max, props.busyHours[0]);
|
|
});
|
|
|
|
// Top 5 Chart configs
|
|
type SupplierData = { name: string; amount: number };
|
|
type CustomerData = { name: string; amount: number };
|
|
type ProductData = { name: string; qty: number };
|
|
|
|
const supplierChartConfig = {
|
|
amount: {
|
|
label: 'Total Pembelian',
|
|
color: 'var(--chart-1)',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const customerChartConfig = {
|
|
amount: {
|
|
label: 'Total Pesanan',
|
|
color: 'var(--chart-2)',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const productChartConfig = {
|
|
qty: {
|
|
label: 'Jumlah Terjual',
|
|
color: 'var(--chart-3)',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const supplierBarData = computed<SupplierData[]>(() => {
|
|
return props.topSuppliers.map((s) => ({
|
|
name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name,
|
|
amount: s.total_amount,
|
|
}));
|
|
});
|
|
|
|
const customerBarData = computed<CustomerData[]>(() => {
|
|
return props.topCustomers.map((c) => ({
|
|
name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name,
|
|
amount: c.total_amount,
|
|
}));
|
|
});
|
|
|
|
const productBarData = computed<ProductData[]>(() => {
|
|
return props.topProducts.map((p) => ({
|
|
name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name,
|
|
qty: p.total_qty,
|
|
}));
|
|
});
|
|
|
|
function applyFilters() {
|
|
router.get(
|
|
admin.analysis.url({
|
|
start_date: startDate.value,
|
|
end_date: endDate.value,
|
|
}),
|
|
{},
|
|
{
|
|
preserveState: true,
|
|
preserveScroll: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
function clearFilters() {
|
|
startDate.value = '';
|
|
endDate.value = '';
|
|
selectedPreset.value = '';
|
|
applyFilters();
|
|
}
|
|
|
|
watch([startDate, endDate], () => {
|
|
applyFilters();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
|
|
<Head title="Analisa" />
|
|
|
|
<AdminLayout>
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="space-y-1">
|
|
<h2 class="text-2xl font-bold tracking-tight">Analisa</h2>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<Select v-model="selectedPreset" @update:model-value="onPresetChange">
|
|
<SelectTrigger class="w-[140px] h-9">
|
|
<SelectValue placeholder="Filter Cepat" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="today">Hari Ini</SelectItem>
|
|
<SelectItem value="week">Minggu Ini</SelectItem>
|
|
<SelectItem value="month">Bulan Ini</SelectItem>
|
|
<SelectItem value="year">Tahun Ini</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Mulai:</span>
|
|
<DatePicker v-model="startDate" class="w-[170px]" placeholder="Pilih tanggal" />
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Sampai:</span>
|
|
<DatePicker v-model="endDate" class="w-[170px]" placeholder="Pilih tanggal" />
|
|
</div>
|
|
<Button v-if="hasActiveFilters" variant="ghost" size="sm" @click="clearFilters" class="h-9 px-3">
|
|
Reset Filter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Kehadiran & Kas Toko -->
|
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard title="Kehadiran" :icon="UserCheck" main-label="Total Karyawan"
|
|
:main-value="attendance.total_employees" :sub-label="attendance.percentage + '% hadir'" :items="[
|
|
{
|
|
label: 'Hadir',
|
|
value: attendance.present,
|
|
},
|
|
{
|
|
label: 'Tidak Hadir',
|
|
value: attendance.absent,
|
|
},
|
|
{
|
|
label: 'Cuti',
|
|
value: attendance.on_leave,
|
|
},
|
|
]" />
|
|
|
|
<StatCard title="Kas Toko" :icon="Banknote" main-label="Total Saldo"
|
|
:main-value="'Rp' + formatRupiah(cashOverview.total_balance)" :sub-label="cashOverview.total_transactions + ' transaksi'
|
|
" :items="[
|
|
{
|
|
label: 'Deposit',
|
|
value: 'Rp' + formatRupiah(cashOverview.total_deposit),
|
|
},
|
|
{
|
|
label: 'Withdrawal',
|
|
value: 'Rp' + formatRupiah(cashOverview.total_withdrawal),
|
|
},
|
|
]" :cols="2" />
|
|
|
|
<StatCard title="Bahan Baku" :icon="Package" main-label="Total Stok"
|
|
:main-value="rawMaterialStock.total_stock.toLocaleString('id-ID')"
|
|
:sub-label="'Rp' + formatRupiah(rawMaterialStock.total_value)" :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',
|
|
},
|
|
]" />
|
|
|
|
<StatCard title="Stok Produk" :icon="ShoppingCart" main-label="Total Stok"
|
|
:main-value="productStock.total_stock.toLocaleString('id-ID')"
|
|
:sub-label="'Rp' + formatRupiah(productStock.total_value) + (productStock.total_reject > 0 ? ' (' + productStock.total_reject + ' reject)' : '')"
|
|
:items="[
|
|
{
|
|
label: 'Produk',
|
|
value: productStock.total_products.toLocaleString('id-ID'),
|
|
},
|
|
{
|
|
label: 'Varian',
|
|
value: productStock.total_variants.toLocaleString('id-ID'),
|
|
},
|
|
{
|
|
label: 'Kategori',
|
|
value: productStock.total_categories.toLocaleString('id-ID'),
|
|
},
|
|
]" />
|
|
</div>
|
|
|
|
<!-- Revenue Chart -->
|
|
<Card class="py-4 sm:py-0">
|
|
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pendapatan</CardTitle>
|
|
</div>
|
|
<div class="flex">
|
|
<button v-for="chart in ['total', 'net', 'potongan', 'ongkir'] as const" :key="chart"
|
|
:data-active="activeRevenueChart === chart"
|
|
class="data-[active=true]:bg-muted/50 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
|
|
@click="activeRevenueChart = chart">
|
|
<span class="text-muted-foreground text-xs">
|
|
{{ revenueChartConfig[chart].label }}
|
|
</span>
|
|
<span class="text-sm">
|
|
Rp{{ formatRupiah(revenueTotals[chart]) }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="px-2 sm:p-6">
|
|
<ChartContainer v-if="monthlyRevenue.length > 0" :config="revenueChartConfig"
|
|
class="aspect-auto h-[300px] w-full">
|
|
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i"
|
|
:y="(d: MonthlyRevenueData) => d[activeRevenueChart]"
|
|
:color="revenueChartConfig[activeRevenueChart].color" :bar-padding="0.1"
|
|
:rounded-corners="4" />
|
|
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false"
|
|
:tick-format="(d: number) => monthlyRevenue[d]?.month ?? ''"
|
|
:tick-values="monthlyRevenue.map((_, i) => i)" />
|
|
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false"
|
|
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pendapatan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Expense Chart -->
|
|
<Card class="py-4 sm:py-0">
|
|
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pengeluaran</CardTitle>
|
|
</div>
|
|
<div class="flex">
|
|
<button v-for="chart in ['total', 'belanja', 'pengeluaran', 'kasbon'] as const" :key="chart"
|
|
:data-active="activeExpenseChart === chart"
|
|
class="data-[active=true]:bg-muted/50 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
|
|
@click="activeExpenseChart = chart">
|
|
<span class="text-muted-foreground text-xs">
|
|
{{ expenseChartConfig[chart].label }}
|
|
</span>
|
|
<span class="text-sm">
|
|
Rp{{ formatRupiah(expenseTotals[chart]) }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="px-2 sm:p-6">
|
|
<ChartContainer v-if="monthlyExpense.length > 0" :config="expenseChartConfig"
|
|
class="aspect-auto h-[300px] w-full">
|
|
<VisXYContainer :data="monthlyExpense" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: MonthlyExpenseData, i: number) => i"
|
|
:y="(d: MonthlyExpenseData) => d[activeExpenseChart]"
|
|
:color="expenseChartConfig[activeExpenseChart].color" :bar-padding="0.1"
|
|
:rounded-corners="4" />
|
|
<VisAxis type="x" :x="(_d: MonthlyExpenseData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false"
|
|
:tick-format="(d: number) => monthlyExpense[d]?.month ?? ''"
|
|
:tick-values="monthlyExpense.map((_, i) => i)" />
|
|
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false"
|
|
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pengeluaran
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Busy Hours Chart -->
|
|
<Card>
|
|
<CardHeader>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Jam Sibuk Toko</CardTitle>
|
|
<CardDescription>Distribusi pesanan berdasarkan jam dalam sehari</CardDescription>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-sm text-muted-foreground">Jam Tersibuk</p>
|
|
<p class="text-2xl font-bold text-primary">{{ peakHour.hour }}</p>
|
|
<p class="text-xs text-muted-foreground">{{ peakHour.orders }} pesanan</p>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="busyHours.length > 0" :config="busyHoursChartConfig"
|
|
class="aspect-auto h-[250px] w-full">
|
|
<VisXYContainer :data="busyHours" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: BusyHourData, i: number) => i" :y="(d: BusyHourData) => d.orders"
|
|
:color="busyHoursChartConfig.orders.color" :bar-padding="0.1" :rounded-corners="4" />
|
|
<VisAxis type="x" :x="(_d: BusyHourData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false"
|
|
:tick-format="(d: number) => busyHours[d]?.hour ?? ''"
|
|
:tick-values="busyHours.map((_, i) => i)" />
|
|
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false"
|
|
:tick-format="(d: number) => Math.round(d).toString()" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pesanan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Profit Metrics Cards -->
|
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard title="Total Order" :icon="ShoppingCart" main-label="Pesanan Selesai"
|
|
:main-value="profitMetrics.total_orders.toLocaleString('id-ID')"
|
|
:sub-label="'AOV: Rp' + formatRupiah(profitMetrics.aov)" :items="[
|
|
{
|
|
label: 'Produk Terjual',
|
|
value: profitMetrics.total_products_sold.toLocaleString('id-ID'),
|
|
},
|
|
{
|
|
label: 'Item/Transaksi',
|
|
value: profitMetrics.items_per_transaction,
|
|
},
|
|
]" />
|
|
|
|
<StatCard title="HPP" :icon="TrendingDown" main-label="Harga Pokok"
|
|
:main-value="'Rp' + formatRupiah(profitMetrics.hpp)" sub-label="Total biaya produksi" :items="[
|
|
{
|
|
label: 'Laba Kotor',
|
|
value: 'Rp' + formatRupiah(profitMetrics.laba_kotor),
|
|
},
|
|
{
|
|
label: 'Laba Bersih',
|
|
value: 'Rp' + formatRupiah(profitMetrics.laba_bersih),
|
|
},
|
|
]" />
|
|
|
|
<StatCard title="Laba Kotor" :icon="TrendingUp" main-label="Gross Profit"
|
|
:main-value="'Rp' + formatRupiah(profitMetrics.laba_kotor)"
|
|
:sub-label="profitMetrics.laba_kotor >= 0 ? 'Positif' : 'Negatif'" :items="[
|
|
{
|
|
label: 'Revenue',
|
|
value: 'Rp' + formatRupiah(revenueSummary.total_revenue),
|
|
},
|
|
{
|
|
label: 'HPP',
|
|
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
|
},
|
|
]" />
|
|
|
|
<StatCard title="Profit Margin" :icon="Percent" main-label="Margin"
|
|
:main-value="profitMetrics.profit_margin + '%'"
|
|
:sub-label="'Laba Bersih: Rp' + formatRupiah(profitMetrics.laba_bersih)" :items="[
|
|
{
|
|
label: 'Laba Kotor',
|
|
value: 'Rp' + formatRupiah(profitMetrics.laba_kotor),
|
|
},
|
|
{
|
|
label: 'Total Pengeluaran',
|
|
value: 'Rp' + formatRupiah(expenseSummary.total),
|
|
},
|
|
]" />
|
|
</div>
|
|
|
|
<!-- Top 5 Charts -->
|
|
<div class="grid gap-4 md:grid-cols-3">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base">Top 5 Supplier</CardTitle>
|
|
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig"
|
|
class="min-h-[250px] w-full">
|
|
<VisXYContainer :data="supplierBarData" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: SupplierData, i: number) => i"
|
|
:y="(d: SupplierData) => d.amount" :color="supplierChartConfig.amount.color"
|
|
:rounded-corners="4" bar-padding="0.1" />
|
|
<VisAxis type="x" :x="(_d: SupplierData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false"
|
|
:tick-format="(d: number) => supplierBarData[d]?.name ?? ''"
|
|
:tick-values="supplierBarData.map((_, i) => i)" />
|
|
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false"
|
|
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data supplier
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
|
|
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig"
|
|
class="min-h-[250px] w-full">
|
|
<VisXYContainer :data="customerBarData" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: CustomerData, i: number) => i"
|
|
:y="(d: CustomerData) => d.amount" :color="customerChartConfig.amount.color"
|
|
:rounded-corners="4" bar-padding="0.1" />
|
|
<VisAxis type="x" :x="(_d: CustomerData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false"
|
|
:tick-format="(d: number) => customerBarData[d]?.name ?? ''"
|
|
:tick-values="customerBarData.map((_, i) => i)" />
|
|
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false"
|
|
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pelanggan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base">Top 5 Produk</CardTitle>
|
|
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="productBarData.length > 0" :config="productChartConfig"
|
|
class="min-h-[250px] w-full">
|
|
<VisXYContainer :data="productBarData" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: ProductData, i: number) => i" :y="(d: ProductData) => d.qty"
|
|
:color="productChartConfig.qty.color" :rounded-corners="4" bar-padding="0.1" />
|
|
<VisAxis type="x" :x="(_d: ProductData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false"
|
|
:tick-format="(d: number) => productBarData[d]?.name ?? ''"
|
|
:tick-values="productBarData.map((_, i) => i)" />
|
|
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data produk
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
</template>
|