store/resources/js/pages/admin/Dashboard.vue

551 lines
24 KiB
Vue

<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import {
Banknote,
Clock,
Moon,
Sun,
Sunrise,
TrendingDown,
TrendingUp,
UserCheck,
} from '@lucide/vue';
import { Donut } from '@unovis/ts';
import { VisAxis, VisDonut, VisGroupedBar, VisSingleContainer, VisXYContainer } from '@unovis/vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import StatCard from '@/components/card/StatCard.vue';
import {
Card,
CardContent,
CardDescription, CardHeader,
CardTitle
} from '@/components/ui/card';
import type { ChartConfig } from '@/components/ui/chart';
import { ChartContainer } from '@/components/ui/chart';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { formatRupiah } from '@/lib/rupiah';
interface DashboardProps {
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;
};
revenueSummary: {
total_revenue: number;
total_discount: number;
total_marketplace_fees: number;
total_potongan: number;
total_shipping: number;
total_orders: number;
avg_order: number;
};
expenseSummary: {
total: number;
purchase_total: number;
expense_total: number;
advance_total: 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;
}>;
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;
}>;
};
}
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 };
});
// 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)', 'var(--chart-4)', 'var(--chart-5)'];
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)', 'var(--chart-5)'];
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 = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];
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,
}));
});
</script>
<template>
<Head title="Dasbor" />
<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>
<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="Pendapatan" :icon="TrendingUp" main-label="Total"
:main-value="'Rp' + formatRupiah(revenueSummary.total_revenue)" :sub-label="revenueSummary.total_orders + ' transaksi selesai'
" :items="[
{
label: 'Bersih',
value: 'Rp' + formatRupiah(
revenueSummary.total_revenue -
revenueSummary.total_potongan,
),
},
{
label: 'Potongan',
value: 'Rp' + formatRupiah(revenueSummary.total_potongan),
},
{
label: 'Diskon',
value: 'Rp' + formatRupiah(revenueSummary.total_discount),
},
]" />
<StatCard title="Pengeluaran" :icon="TrendingDown" main-label="Total"
:main-value="'Rp' + formatRupiah(expenseSummary.total)" :items="[
{
label: 'Belanja',
value: 'Rp' + formatRupiah(expenseSummary.purchase_total),
},
{
label: 'Pengeluaran',
value: 'Rp' + formatRupiah(expenseSummary.expense_total),
},
{
label: 'Kasbon',
value: 'Rp' + formatRupiah(expenseSummary.advance_total),
},
]" />
</div>
<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 class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader>
<CardTitle class="text-base">Pesanan per Channel</CardTitle>
</CardHeader>
<CardContent>
<div v-if="orderStats.by_channel.length > 0">
<ChartContainer :config="channelChartConfig" class="mx-auto aspect-square max-h-[200px]">
<VisSingleContainer :data="orderStats.by_channel" :margin="{ top: 10, bottom: 10 }">
<VisDonut :value="(d: (typeof orderStats.by_channel)[number]) => d.count"
:color="(d: (typeof orderStats.by_channel)[number]) => channelChartConfig[d.channel]?.color ?? 'var(--chart-1)'"
:arc-width="30" />
</VisSingleContainer>
</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>
<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="mx-auto aspect-square max-h-[200px]">
<VisSingleContainer :data="orderStats.by_payment_type"
:margin="{ top: 10, bottom: 10 }">
<VisDonut :value="(d: (typeof orderStats.by_payment_type)[number]) => d.count"
:color="(d: (typeof orderStats.by_payment_type)[number]) => paymentChartConfig[d.payment_type]?.color ?? 'var(--chart-1)'"
:arc-width="30" />
</VisSingleContainer>
</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>
<Card>
<CardHeader>
<CardTitle class="text-base">Pesanan per Marketing</CardTitle>
</CardHeader>
<CardContent>
<div v-if="orderStats.by_marketing.length > 0">
<ChartContainer :config="marketingChartConfig" class="mx-auto aspect-square max-h-[200px]">
<VisSingleContainer :data="orderStats.by_marketing"
:margin="{ top: 10, bottom: 10 }">
<VisDonut :value="(d: (typeof orderStats.by_marketing)[number]) => d.count"
:color="(_d: (typeof orderStats.by_marketing)[number], i: number) => marketingColors[i % marketingColors.length]"
:arc-width="30" />
</VisSingleContainer>
</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>
<Card>
<CardHeader>
<CardTitle class="text-base">Pesanan per Status</CardTitle>
</CardHeader>
<CardContent>
<div v-if="orderStats.by_status.length > 0">
<ChartContainer :config="statusChartConfig" class="mx-auto aspect-square max-h-[200px]">
<VisSingleContainer :data="orderStats.by_status" :margin="{ top: 10, bottom: 10 }">
<VisDonut :value="(d: (typeof orderStats.by_status)[number]) => d.count"
:color="(d: (typeof orderStats.by_status)[number]) => statusChartConfig[d.status]?.color ?? 'var(--chart-1)'"
:arc-width="30" />
</VisSingleContainer>
</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>
</div>
</AdminLayout>
</template>