1186 lines
45 KiB
Vue
1186 lines
45 KiB
Vue
<script setup lang="ts">
|
|
import LowStockCard from '@/components/card/LowStockCard.vue';
|
|
import StatCard from '@/components/card/StatCard.vue';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import type { ChartConfig } from '@/components/ui/chart';
|
|
import {
|
|
ChartContainer,
|
|
ChartCrosshair,
|
|
ChartTooltip,
|
|
ChartTooltipContent,
|
|
componentToString,
|
|
} from '@/components/ui/chart';
|
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import { Head } from '@inertiajs/vue3';
|
|
import {
|
|
AlertTriangle,
|
|
Banknote,
|
|
Clock,
|
|
CreditCard,
|
|
FileText,
|
|
Moon,
|
|
Package,
|
|
ShoppingCart,
|
|
Sun,
|
|
Sunrise,
|
|
TrendingDown,
|
|
TrendingUp,
|
|
UserCheck,
|
|
} from '@lucide/vue';
|
|
import { VisAxis, VisDonut, VisGroupedBar, VisXYContainer } from '@unovis/vue';
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
|
|
interface DashboardProps {
|
|
rawMaterialStock: {
|
|
total_stock: number;
|
|
total_value: number;
|
|
by_unit: Record<string, number>;
|
|
};
|
|
productStock: {
|
|
total_stock: number;
|
|
total_reject: number;
|
|
total_value: number;
|
|
total_variants: number;
|
|
total_products: number;
|
|
total_categories: number;
|
|
};
|
|
lowStockProducts: Array<{
|
|
name: string;
|
|
stock: number;
|
|
}>;
|
|
lowStockMaterials: Array<{
|
|
name: string;
|
|
stock: number;
|
|
unit: string;
|
|
}>;
|
|
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;
|
|
}>;
|
|
purchaseSummary: {
|
|
total_purchases: number;
|
|
total_spent: number;
|
|
total_discount: number;
|
|
};
|
|
cuttingSummary: {
|
|
total_cost: number;
|
|
total_cutting: number;
|
|
total_warehouse: number;
|
|
total_reject: number;
|
|
};
|
|
cuttingByStatus: Array<{
|
|
status: string;
|
|
label: string;
|
|
count: number;
|
|
}>;
|
|
orderStats: {
|
|
by_channel: Array<{
|
|
channel: string;
|
|
label: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_payment_type: Array<{
|
|
payment_type: string;
|
|
label: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_marketing: Array<{
|
|
name: string;
|
|
count: number;
|
|
total: number;
|
|
}>;
|
|
by_status: Array<{
|
|
status: string;
|
|
label: string;
|
|
count: number;
|
|
}>;
|
|
};
|
|
revenueSummary: {
|
|
total_revenue: number;
|
|
total_discount: number;
|
|
total_marketplace_fees: number;
|
|
total_potongan: number;
|
|
total_shipping: number;
|
|
total_orders: number;
|
|
avg_order: number;
|
|
};
|
|
marketplaceSummary: {
|
|
total_revenue: number;
|
|
total_fees: number;
|
|
total_net: number;
|
|
total_orders: number;
|
|
};
|
|
cashAccounts: Array<{
|
|
name: string;
|
|
balance: number;
|
|
}>;
|
|
cashSummary: {
|
|
total_transactions: number;
|
|
total_deposit: number;
|
|
total_withdrawal: number;
|
|
};
|
|
monthlyCashFlow: Array<{
|
|
label: string;
|
|
deposits: number;
|
|
withdrawals: number;
|
|
}>;
|
|
monthlyExpenses: {
|
|
total: number;
|
|
count: number;
|
|
};
|
|
kasbonSummary: {
|
|
pending: { count: number; total: number };
|
|
approved: { count: number; total: number };
|
|
paid: { count: number; total: number };
|
|
total: number;
|
|
total_employees: number;
|
|
};
|
|
payrollSummary: {
|
|
has_period: boolean;
|
|
period_status?: string;
|
|
total_employees: number;
|
|
total_amount: number;
|
|
paid_amount: number;
|
|
unpaid_amount: number;
|
|
paid_count: number;
|
|
unpaid_count: number;
|
|
};
|
|
leaveRequestSummary: {
|
|
total: number;
|
|
pending: number;
|
|
approved: number;
|
|
rejected: number;
|
|
};
|
|
employeeSummary: {
|
|
total: number;
|
|
by_status: Array<{
|
|
status: string;
|
|
label: string;
|
|
count: number;
|
|
}>;
|
|
};
|
|
attendanceToday: {
|
|
total_employees: number;
|
|
present: number;
|
|
absent: number;
|
|
on_leave: number;
|
|
};
|
|
monthlyRevenueTrend: Array<{
|
|
label: string;
|
|
total: number;
|
|
count: number;
|
|
}>;
|
|
monthlyPurchaseTrend: Array<{
|
|
label: string;
|
|
total: number;
|
|
count: number;
|
|
}>;
|
|
}
|
|
|
|
const props = defineProps<DashboardProps>();
|
|
|
|
const currentTime = ref(new Date());
|
|
let timer: ReturnType<typeof setInterval>;
|
|
|
|
onMounted(() => {
|
|
timer = setInterval(() => {
|
|
currentTime.value = new Date();
|
|
}, 1000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(timer);
|
|
});
|
|
|
|
const timeStr = computed(() => {
|
|
return currentTime.value.toLocaleTimeString('id-ID', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
});
|
|
|
|
const dateStr = computed(() => {
|
|
return currentTime.value.toLocaleDateString('id-ID', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
});
|
|
|
|
const greeting = computed(() => {
|
|
const hour = currentTime.value.getHours();
|
|
|
|
if (hour >= 4 && hour < 11) {
|
|
return { text: 'Selamat Pagi', icon: Sunrise };
|
|
}
|
|
|
|
if (hour >= 11 && hour < 15) {
|
|
return { text: 'Selamat Siang', icon: Sun };
|
|
}
|
|
|
|
if (hour >= 15 && hour < 18) {
|
|
return { text: 'Selamat Sore', icon: Sun };
|
|
}
|
|
|
|
return { text: 'Selamat Malam', icon: Moon };
|
|
});
|
|
|
|
function formatRupiah(value: number): string {
|
|
return 'Rp ' + value.toLocaleString('id-ID');
|
|
}
|
|
|
|
// Chart configs
|
|
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 channelColors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)'];
|
|
const channelChartConfig = computed(() => {
|
|
const config: ChartConfig = {};
|
|
props.orderStats.by_channel.forEach((item, index) => {
|
|
config[item.channel] = {
|
|
label: item.label,
|
|
color: channelColors[index % channelColors.length],
|
|
};
|
|
});
|
|
|
|
return config;
|
|
});
|
|
|
|
const paymentColors = [
|
|
'var(--chart-1)',
|
|
'var(--chart-2)',
|
|
'var(--chart-3)',
|
|
'var(--chart-4)',
|
|
];
|
|
const paymentChartConfig = computed(() => {
|
|
const config: ChartConfig = {};
|
|
props.orderStats.by_payment_type.forEach((item, index) => {
|
|
config[item.payment_type] = {
|
|
label: item.label,
|
|
color: paymentColors[index % paymentColors.length],
|
|
};
|
|
});
|
|
|
|
return config;
|
|
});
|
|
|
|
const statusColors = [
|
|
'#facc15',
|
|
'var(--chart-1)',
|
|
'var(--chart-2)',
|
|
'var(--chart-4)',
|
|
];
|
|
const statusChartConfig = computed(() => {
|
|
const config: ChartConfig = {};
|
|
props.orderStats.by_status.forEach((item, index) => {
|
|
config[item.status] = {
|
|
label: item.label,
|
|
color: statusColors[index % statusColors.length],
|
|
};
|
|
});
|
|
|
|
return config;
|
|
});
|
|
|
|
const marketingColors = [
|
|
'var(--chart-1)',
|
|
'var(--chart-2)',
|
|
'var(--chart-3)',
|
|
'var(--chart-4)',
|
|
'var(--chart-5)',
|
|
];
|
|
const marketingChartConfig = computed(() => {
|
|
const config: ChartConfig = {};
|
|
props.orderStats.by_marketing.forEach((item, index) => {
|
|
config[`mkt_${index}`] = {
|
|
label: item.name,
|
|
color: marketingColors[index % marketingColors.length],
|
|
};
|
|
});
|
|
|
|
return config;
|
|
});
|
|
|
|
type SupplierData = { name: string; amount: number };
|
|
type CustomerData = { name: string; amount: number };
|
|
type ProductData = { name: string; qty: number };
|
|
|
|
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,
|
|
}));
|
|
});
|
|
|
|
const totalCashBalance = computed(() => {
|
|
return props.cashAccounts.reduce((sum, acc) => sum + acc.balance, 0);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Head title="Dashboard" />
|
|
|
|
<AdminLayout>
|
|
<div class="flex flex-1 flex-col gap-6">
|
|
<!-- Welcome Card -->
|
|
<Card class="relative overflow-hidden">
|
|
<div
|
|
class="absolute inset-0 bg-linear-to-br from-primary/5 to-background"
|
|
/>
|
|
<CardHeader class="relative">
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex size-12 items-center justify-center rounded-full bg-primary/10"
|
|
>
|
|
<component
|
|
:is="greeting.icon"
|
|
class="size-6 text-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<CardTitle class="text-2xl font-bold">
|
|
{{ greeting.text }},
|
|
{{ $page.props.auth.user?.username }}!
|
|
</CardTitle>
|
|
<CardDescription class="mt-1">
|
|
Selamat datang di dasbor aplikasi.
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="relative">
|
|
<div
|
|
class="flex items-center gap-2 text-sm text-muted-foreground"
|
|
>
|
|
<span>{{ dateStr }}</span>
|
|
<span>-</span>
|
|
<span class="flex items-center gap-1">
|
|
<Clock class="size-3.5" />
|
|
{{ timeStr }}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Row 1: Stock & Finance Summary -->
|
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<!-- 1. Raw Material Stock -->
|
|
<StatCard
|
|
title="Bahan Baku"
|
|
:icon="Package"
|
|
main-label="Total Stok"
|
|
:main-value="
|
|
rawMaterialStock.total_stock.toLocaleString('id-ID')
|
|
"
|
|
:sub-label="formatRupiah(rawMaterialStock.total_value)"
|
|
:items="[
|
|
{
|
|
label: 'Yard',
|
|
value: (
|
|
rawMaterialStock.by_unit?.yard ?? 0
|
|
).toLocaleString('id-ID'),
|
|
},
|
|
{
|
|
label: 'Meter',
|
|
value: (
|
|
rawMaterialStock.by_unit?.meter ?? 0
|
|
).toLocaleString('id-ID'),
|
|
},
|
|
{
|
|
label: 'Kg',
|
|
value: (
|
|
rawMaterialStock.by_unit?.kilogram ?? 0
|
|
).toLocaleString('id-ID'),
|
|
},
|
|
]"
|
|
/>
|
|
|
|
<!-- 2. Product Stock -->
|
|
<StatCard
|
|
title="Stok Produk"
|
|
:icon="ShoppingCart"
|
|
main-label="Total Stok"
|
|
:main-value="
|
|
productStock.total_stock.toLocaleString('id-ID')
|
|
"
|
|
:sub-label="
|
|
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',
|
|
),
|
|
},
|
|
]"
|
|
/>
|
|
|
|
<!-- Attendance Today -->
|
|
<StatCard
|
|
title="Kehadiran"
|
|
:icon="UserCheck"
|
|
main-label="Total Karyawan"
|
|
:main-value="attendanceToday.total_employees"
|
|
:sub-label="
|
|
(attendanceToday.total_employees > 0
|
|
? Math.round(
|
|
(attendanceToday.present /
|
|
attendanceToday.total_employees) *
|
|
100,
|
|
)
|
|
: 0) + '% hadir'
|
|
"
|
|
:items="[
|
|
{
|
|
label: 'Hadir',
|
|
value: attendanceToday.present,
|
|
},
|
|
{
|
|
label: 'Tidak Hadir',
|
|
value: attendanceToday.absent,
|
|
},
|
|
{
|
|
label: 'Cuti',
|
|
value: attendanceToday.on_leave ?? 0,
|
|
},
|
|
]"
|
|
/>
|
|
|
|
<!-- Cash Accounts -->
|
|
<StatCard
|
|
title="Kas Toko"
|
|
:icon="Banknote"
|
|
main-label="Total Saldo"
|
|
:main-value="formatRupiah(totalCashBalance)"
|
|
:sub-label="
|
|
cashSummary.total_transactions + ' transaksi bulan ini'
|
|
"
|
|
:items="[
|
|
{
|
|
label: 'Deposit',
|
|
value: formatRupiah(cashSummary.total_deposit),
|
|
},
|
|
{
|
|
label: 'Withdrawal',
|
|
value: formatRupiah(cashSummary.total_withdrawal),
|
|
},
|
|
]"
|
|
:cols="2"
|
|
/>
|
|
|
|
<!-- Kasbon Summary -->
|
|
<StatCard
|
|
title="Kasbon Karyawan"
|
|
:icon="CreditCard"
|
|
main-label="Total Keseluruhan"
|
|
:main-value="formatRupiah(kasbonSummary.total)"
|
|
:sub-label="kasbonSummary.total_employees + ' karyawan'"
|
|
:items="[
|
|
{
|
|
label: 'Menunggu',
|
|
value: formatRupiah(kasbonSummary.pending.total),
|
|
},
|
|
{
|
|
label: 'Disetujui',
|
|
value: formatRupiah(kasbonSummary.approved.total),
|
|
},
|
|
{
|
|
label: 'Dibayar',
|
|
value: formatRupiah(kasbonSummary.paid.total),
|
|
},
|
|
]"
|
|
/>
|
|
|
|
<!-- Leave Request Summary -->
|
|
<StatCard
|
|
title="Pengajuan Cuti Bulan Ini"
|
|
:icon="FileText"
|
|
main-label="Total Pengajuan"
|
|
:main-value="leaveRequestSummary.total"
|
|
:items="[
|
|
{
|
|
label: 'Menunggu',
|
|
value: leaveRequestSummary.pending,
|
|
},
|
|
{
|
|
label: 'Disetujui',
|
|
value: leaveRequestSummary.approved,
|
|
},
|
|
{
|
|
label: 'Ditolak',
|
|
value: leaveRequestSummary.rejected,
|
|
},
|
|
]"
|
|
/>
|
|
|
|
<!-- Total Pengeluaran -->
|
|
<StatCard
|
|
title="Total Pengeluaran"
|
|
:icon="TrendingDown"
|
|
main-label="Total"
|
|
:main-value="
|
|
formatRupiah(
|
|
purchaseSummary.total_spent +
|
|
monthlyExpenses.total +
|
|
kasbonSummary.total,
|
|
)
|
|
"
|
|
:items="[
|
|
{
|
|
label: 'Belanja',
|
|
value: formatRupiah(purchaseSummary.total_spent),
|
|
},
|
|
{
|
|
label: 'Pengeluaran',
|
|
value: formatRupiah(monthlyExpenses.total),
|
|
},
|
|
{
|
|
label: 'Kasbon',
|
|
value: formatRupiah(kasbonSummary.total),
|
|
},
|
|
]"
|
|
/>
|
|
|
|
<!-- Total Pendapatan -->
|
|
<StatCard
|
|
title="Total Pendapatan"
|
|
:icon="TrendingUp"
|
|
main-label="Total"
|
|
:main-value="formatRupiah(revenueSummary.total_revenue)"
|
|
:sub-label="
|
|
revenueSummary.total_orders + ' transaksi selesai'
|
|
"
|
|
:items="[
|
|
{
|
|
label: 'Bersih',
|
|
value: formatRupiah(
|
|
revenueSummary.total_revenue -
|
|
revenueSummary.total_potongan,
|
|
),
|
|
},
|
|
{
|
|
label: 'Potongan',
|
|
value: formatRupiah(revenueSummary.total_potongan),
|
|
},
|
|
{
|
|
label: 'Diskon',
|
|
value: formatRupiah(revenueSummary.total_discount),
|
|
},
|
|
]"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Row 2: Low Stock Alerts -->
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<!-- Low Stock Products -->
|
|
<LowStockCard
|
|
title="Stok Produk Menipis"
|
|
:icon="AlertTriangle"
|
|
:items="lowStockProducts"
|
|
empty-text="Semua stok produk aman"
|
|
/>
|
|
|
|
<!-- Low Stock Materials -->
|
|
<LowStockCard
|
|
title="Stok Bahan Baku Menipis"
|
|
:icon="AlertTriangle"
|
|
:items="lowStockMaterials"
|
|
empty-text="Semua stok bahan baku aman"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Row 3: Top 5 Charts -->
|
|
<div class="grid gap-4 md:grid-cols-3">
|
|
<!-- Top 5 Suppliers Chart -->
|
|
<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">
|
|
<VisGroupedBar
|
|
:x="(d: SupplierData) => d.name"
|
|
:y="(d: SupplierData) => d.amount"
|
|
:color="supplierChartConfig.amount.color"
|
|
:rounded-corners="4"
|
|
bar-padding="0.1"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
v-else
|
|
class="flex h-[250px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data supplier
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Top 5 Customers Chart -->
|
|
<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">
|
|
<VisGroupedBar
|
|
:x="(d: CustomerData) => d.name"
|
|
:y="(d: CustomerData) => d.amount"
|
|
:color="customerChartConfig.amount.color"
|
|
:rounded-corners="4"
|
|
bar-padding="0.1"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
v-else
|
|
class="flex h-[250px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data pelanggan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Top 5 Products Chart -->
|
|
<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"
|
|
:margin="{ left: -24 }"
|
|
:y-domain="[0, undefined]"
|
|
>
|
|
<VisGroupedBar
|
|
:x="(d: ProductData) => d.name"
|
|
:y="(d: ProductData) => d.qty"
|
|
:color="productChartConfig.qty.color"
|
|
:rounded-corners="10"
|
|
bar-padding="0.1"
|
|
/>
|
|
<VisAxis
|
|
type="x"
|
|
:x="(d: ProductData) => d.name"
|
|
:tick-line="false"
|
|
:domain-line="false"
|
|
:grid-line="false"
|
|
:num-ticks="productBarData.length"
|
|
:tick-values="productBarData.map(d => d.name)"
|
|
/>
|
|
<VisAxis
|
|
type="y"
|
|
:num-ticks="3"
|
|
:tick-line="false"
|
|
:domain-line="false"
|
|
/>
|
|
<ChartTooltip />
|
|
<ChartCrosshair
|
|
:template="
|
|
componentToString(
|
|
productChartConfig,
|
|
ChartTooltipContent,
|
|
{ hideLabel: true },
|
|
)
|
|
"
|
|
color="#0000"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
v-else
|
|
class="flex h-[250px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data produk
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter class="flex-col items-start gap-2 text-sm">
|
|
<div class="flex gap-2 font-medium leading-none text-muted-foreground">
|
|
Menampilkan 5 produk dengan penjualan tertinggi <TrendingUp class="h-4 w-4 text-green-500" />
|
|
</div>
|
|
</CardFooter>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Row 4: Order Stats Charts -->
|
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<!-- Order by Channel -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base"
|
|
>Pesanan per Channel</CardTitle
|
|
>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="orderStats.by_channel.length > 0">
|
|
<ChartContainer
|
|
:config="channelChartConfig"
|
|
class="min-h-[200px] w-full"
|
|
>
|
|
<VisXYContainer :data="orderStats.by_channel">
|
|
<VisDonut
|
|
:value="
|
|
(
|
|
d: (typeof orderStats.by_channel)[number],
|
|
) => d.count
|
|
"
|
|
:color="
|
|
orderStats.by_channel.map(
|
|
(_, i) =>
|
|
channelColors[
|
|
i % channelColors.length
|
|
],
|
|
)
|
|
"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
class="mt-3 flex flex-wrap justify-center gap-3"
|
|
>
|
|
<div
|
|
v-for="(
|
|
item, index
|
|
) in orderStats.by_channel"
|
|
:key="item.channel"
|
|
class="flex items-center gap-1.5"
|
|
>
|
|
<span
|
|
class="size-2.5 rounded-full"
|
|
:style="{
|
|
backgroundColor:
|
|
channelColors[
|
|
index % channelColors.length
|
|
],
|
|
}"
|
|
/>
|
|
<span
|
|
class="text-xs text-muted-foreground"
|
|
>{{ item.label }}</span
|
|
>
|
|
<span class="text-xs font-medium">{{
|
|
item.count
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-[200px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Order by Payment Type -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base"
|
|
>Pesanan per Pembayaran</CardTitle
|
|
>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="orderStats.by_payment_type.length > 0">
|
|
<ChartContainer
|
|
:config="paymentChartConfig"
|
|
class="min-h-[200px] w-full"
|
|
>
|
|
<VisXYContainer
|
|
:data="orderStats.by_payment_type"
|
|
>
|
|
<VisDonut
|
|
:value="
|
|
(
|
|
d: (typeof orderStats.by_payment_type)[number],
|
|
) => d.count
|
|
"
|
|
:color="
|
|
orderStats.by_payment_type.map(
|
|
(_, i) =>
|
|
paymentColors[
|
|
i % paymentColors.length
|
|
],
|
|
)
|
|
"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
class="mt-3 flex flex-wrap justify-center gap-3"
|
|
>
|
|
<div
|
|
v-for="(
|
|
item, index
|
|
) in orderStats.by_payment_type"
|
|
:key="item.payment_type"
|
|
class="flex items-center gap-1.5"
|
|
>
|
|
<span
|
|
class="size-2.5 rounded-full"
|
|
:style="{
|
|
backgroundColor:
|
|
paymentColors[
|
|
index % paymentColors.length
|
|
],
|
|
}"
|
|
/>
|
|
<span
|
|
class="text-xs text-muted-foreground"
|
|
>{{ item.label }}</span
|
|
>
|
|
<span class="text-xs font-medium">{{
|
|
item.count
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-[200px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Order by Marketing -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base"
|
|
>Pesanan per Marketing</CardTitle
|
|
>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="orderStats.by_marketing.length > 0">
|
|
<ChartContainer
|
|
:config="marketingChartConfig"
|
|
class="min-h-[200px] w-full"
|
|
>
|
|
<VisXYContainer :data="orderStats.by_marketing">
|
|
<VisDonut
|
|
:value="
|
|
(
|
|
d: (typeof orderStats.by_marketing)[number],
|
|
) => d.count
|
|
"
|
|
:color="
|
|
orderStats.by_marketing.map(
|
|
(_, i) =>
|
|
marketingColors[
|
|
i %
|
|
marketingColors.length
|
|
],
|
|
)
|
|
"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
class="mt-3 flex flex-wrap justify-center gap-3"
|
|
>
|
|
<div
|
|
v-for="(
|
|
item, index
|
|
) in orderStats.by_marketing"
|
|
:key="item.name"
|
|
class="flex items-center gap-1.5"
|
|
>
|
|
<span
|
|
class="size-2.5 rounded-full"
|
|
:style="{
|
|
backgroundColor:
|
|
marketingColors[
|
|
index %
|
|
marketingColors.length
|
|
],
|
|
}"
|
|
/>
|
|
<span
|
|
class="text-xs text-muted-foreground"
|
|
>{{ item.name }}</span
|
|
>
|
|
<span class="text-xs font-medium">{{
|
|
item.count
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-[200px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Order by Status -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base"
|
|
>Pesanan per Status</CardTitle
|
|
>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="orderStats.by_status.length > 0">
|
|
<ChartContainer
|
|
:config="statusChartConfig"
|
|
class="min-h-[200px] w-full"
|
|
>
|
|
<VisXYContainer :data="orderStats.by_status">
|
|
<VisDonut
|
|
:value="
|
|
(
|
|
d: (typeof orderStats.by_status)[number],
|
|
) => d.count
|
|
"
|
|
:color="
|
|
orderStats.by_status.map(
|
|
(_, i) =>
|
|
statusColors[
|
|
i % statusColors.length
|
|
],
|
|
)
|
|
"
|
|
/>
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div
|
|
class="mt-3 flex flex-wrap justify-center gap-3"
|
|
>
|
|
<div
|
|
v-for="(
|
|
item, index
|
|
) in orderStats.by_status"
|
|
:key="item.status"
|
|
class="flex items-center gap-1.5"
|
|
>
|
|
<span
|
|
class="size-2.5 rounded-full"
|
|
:style="{
|
|
backgroundColor:
|
|
statusColors[
|
|
index % statusColors.length
|
|
],
|
|
}"
|
|
/>
|
|
<span
|
|
class="text-xs text-muted-foreground"
|
|
>{{ item.label }}</span
|
|
>
|
|
<span class="text-xs font-medium">{{
|
|
item.count
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-[200px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Row 8: Monthly Trends -->
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<!-- Monthly Revenue Trend -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base"
|
|
>Tren Pendapatan 6 Bulan</CardTitle
|
|
>
|
|
<CardDescription
|
|
>Total pendapatan per bulan</CardDescription
|
|
>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div
|
|
v-if="monthlyRevenueTrend.length > 0"
|
|
class="space-y-2"
|
|
>
|
|
<div
|
|
v-for="item in monthlyRevenueTrend"
|
|
:key="item.label"
|
|
class="flex items-center gap-3"
|
|
>
|
|
<span
|
|
class="w-16 text-xs text-muted-foreground"
|
|
>{{ item.label }}</span
|
|
>
|
|
<div
|
|
class="h-4 flex-1 overflow-hidden rounded-full bg-muted"
|
|
>
|
|
<div
|
|
class="h-full rounded-full bg-green-500 transition-all"
|
|
:style="{
|
|
width: `${Math.max(5, (item.total / Math.max(...monthlyRevenueTrend.map((r) => r.total))) * 100)}%`,
|
|
}"
|
|
/>
|
|
</div>
|
|
<span
|
|
class="w-24 text-right text-xs font-medium"
|
|
>{{ formatRupiah(item.total) }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-[150px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Monthly Purchase Trend -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base"
|
|
>Tren Pembelian 6 Bulan</CardTitle
|
|
>
|
|
<CardDescription
|
|
>Total pembelian per bulan</CardDescription
|
|
>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div
|
|
v-if="monthlyPurchaseTrend.length > 0"
|
|
class="space-y-2"
|
|
>
|
|
<div
|
|
v-for="item in monthlyPurchaseTrend"
|
|
:key="item.label"
|
|
class="flex items-center gap-3"
|
|
>
|
|
<span
|
|
class="w-16 text-xs text-muted-foreground"
|
|
>{{ item.label }}</span
|
|
>
|
|
<div
|
|
class="h-4 flex-1 overflow-hidden rounded-full bg-muted"
|
|
>
|
|
<div
|
|
class="h-full rounded-full bg-blue-500 transition-all"
|
|
:style="{
|
|
width: `${Math.max(5, (item.total / Math.max(...monthlyPurchaseTrend.map((r) => r.total))) * 100)}%`,
|
|
}"
|
|
/>
|
|
</div>
|
|
<span
|
|
class="w-24 text-right text-xs font-medium"
|
|
>{{ formatRupiah(item.total) }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-[150px] items-center justify-center text-muted-foreground"
|
|
>
|
|
Belum ada data
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
</template>
|