595 lines
23 KiB
Vue
595 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { Head } from '@inertiajs/vue3';
|
|
import {
|
|
Clock,
|
|
Moon,
|
|
Sun,
|
|
Sunrise,
|
|
TrendingDown,
|
|
TrendingUp,
|
|
UserCheck,
|
|
} from '@lucide/vue';
|
|
import { Donut } from '@unovis/ts';
|
|
import { VisDonut, VisSingleContainer, VisTooltip } from '@unovis/vue';
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
import AttendanceCard from '@/components/card/AttendanceCard.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 { useCan } from '@/composables/useCan';
|
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import { formatRupiah } from '@/lib/rupiah';
|
|
import AttendanceWebcamModal from '@/pages/admin/hr/attendances/form/AttendanceWebcamModal.vue';
|
|
import type { TodayAttendance } from '@/types/attendance';
|
|
|
|
interface DashboardProps {
|
|
attendance: {
|
|
total_employees: number;
|
|
percentage: number;
|
|
present: number;
|
|
absent: number;
|
|
on_leave: number;
|
|
};
|
|
revenueSummary: {
|
|
total_revenue: number;
|
|
total_discount: number;
|
|
total_marketplace_fees: number;
|
|
total_deduction: number;
|
|
total_orders: number;
|
|
avg_order: number;
|
|
};
|
|
expenseSummary: {
|
|
total: number;
|
|
purchase_total: number;
|
|
expense_total: number;
|
|
advance_total: 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;
|
|
}>;
|
|
};
|
|
todayAttendance: TodayAttendance;
|
|
isOnLeave: boolean;
|
|
canCheckIn: boolean;
|
|
}
|
|
|
|
const props = defineProps<DashboardProps>();
|
|
|
|
const { can, hasRole } = useCan();
|
|
|
|
const webcamModalOpen = ref(false);
|
|
const webcamMode = ref<'check-in' | 'check-out'>('check-in');
|
|
|
|
function openCheckInModal() {
|
|
webcamMode.value = 'check-in';
|
|
webcamModalOpen.value = true;
|
|
}
|
|
|
|
function openCheckOutModal() {
|
|
webcamMode.value = 'check-out';
|
|
webcamModalOpen.value = true;
|
|
}
|
|
|
|
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 - each segment uses a different color family for clarity
|
|
const allChartColors = [
|
|
'#60a5fa', // biru
|
|
'#f97316', // oranye
|
|
'#22c55e', // hijau
|
|
'#a855f7', // ungu
|
|
'#ef4444', // merah
|
|
];
|
|
|
|
const channelColors = allChartColors;
|
|
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 = allChartColors;
|
|
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 = allChartColors;
|
|
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 = allChartColors;
|
|
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;
|
|
});
|
|
|
|
// Tooltip triggers for donut charts
|
|
const donutSegmentSelector = Donut.selectors.segment;
|
|
|
|
function channelTooltip(arc: any) {
|
|
const d = arc.data as typeof props.orderStats.by_channel[number];
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${channelChartConfig.value[d.channel]?.color ?? channelColors[0]}"></span>
|
|
<span class="text-muted-foreground">${d.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">${d.count.toLocaleString('id-ID')}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function paymentTooltip(arc: any) {
|
|
const d = arc.data as typeof props.orderStats.by_payment_type[number];
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${paymentChartConfig.value[d.payment_type]?.color ?? paymentColors[0]}"></span>
|
|
<span class="text-muted-foreground">${d.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">${d.count.toLocaleString('id-ID')}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function marketingTooltip(arc: any) {
|
|
const d = arc.data as typeof props.orderStats.by_marketing[number];
|
|
const idx = props.orderStats.by_marketing.indexOf(d);
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${marketingColors[idx % marketingColors.length]}"></span>
|
|
<span class="text-muted-foreground">${d.name}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">${d.count.toLocaleString('id-ID')}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function statusTooltip(arc: any) {
|
|
const d = arc.data as typeof props.orderStats.by_status[number];
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${statusChartConfig.value[d.status]?.color ?? statusColors[0]}"></span>
|
|
<span class="text-muted-foreground">${d.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">${d.count.toLocaleString('id-ID')}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
const visibleStatsCount = computed(() => {
|
|
let count = 0;
|
|
|
|
if (can('dashboard.attendance')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.revenue')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.expense')) {
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
});
|
|
|
|
const statsGridClass = computed(() => {
|
|
const count = visibleStatsCount.value;
|
|
|
|
if (count === 1) {
|
|
return 'grid gap-4 grid-cols-1 md:max-w-md';
|
|
}
|
|
|
|
if (count === 2) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
|
|
}
|
|
|
|
if (count === 3) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
|
|
}
|
|
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
|
|
});
|
|
|
|
const visibleChartsCount = computed(() => {
|
|
let count = 0;
|
|
|
|
if (can('dashboard.orders_channel')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.orders_payment')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.orders_marketing')) {
|
|
count++;
|
|
}
|
|
|
|
if (can('dashboard.orders_status')) {
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
});
|
|
|
|
const chartsGridClass = computed(() => {
|
|
const count = visibleChartsCount.value;
|
|
|
|
if (count === 1) {
|
|
return 'grid gap-4 grid-cols-1 md:max-w-xl';
|
|
}
|
|
|
|
if (count === 2) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
|
|
}
|
|
|
|
if (count === 3) {
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
|
|
}
|
|
|
|
return 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
|
|
});
|
|
</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>
|
|
|
|
<AttendanceCard :today-attendance="todayAttendance" :is-on-leave="isOnLeave" :can-check-in="canCheckIn"
|
|
@check-in="openCheckInModal" @check-out="openCheckOutModal" />
|
|
|
|
<div v-if="visibleStatsCount > 0" :class="statsGridClass">
|
|
<StatCard v-if="can('dashboard.attendance')" 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 v-if="can('dashboard.revenue')" 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_deduction,
|
|
),
|
|
},
|
|
{
|
|
label: 'Potongan',
|
|
value: 'Rp' + formatRupiah(revenueSummary.total_marketplace_fees ?? 0),
|
|
},
|
|
{
|
|
label: 'Diskon',
|
|
value: 'Rp' + formatRupiah(revenueSummary.total_discount),
|
|
},
|
|
]" />
|
|
|
|
<StatCard v-if="can('dashboard.expense')" title="Pengeluaran" :icon="TrendingDown" main-label="Total"
|
|
:main-value="'Rp' + formatRupiah(expenseSummary.total)" :items="[
|
|
...(!hasRole('admin-toko') ? [{
|
|
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 v-if="visibleChartsCount > 0" :class="chartsGridClass">
|
|
<Card v-if="can('dashboard.orders_channel')">
|
|
<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] chart-channel">
|
|
<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 ?? channelColors[0]"
|
|
:arc-width="30" />
|
|
<VisTooltip :triggers="{ [donutSegmentSelector]: channelTooltip }"
|
|
class-name="custom-tooltip" />
|
|
</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 v-if="can('dashboard.orders_payment')">
|
|
<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] chart-payment">
|
|
<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 ?? paymentColors[0]"
|
|
:arc-width="30" />
|
|
<VisTooltip :triggers="{ [donutSegmentSelector]: paymentTooltip }"
|
|
class-name="custom-tooltip" />
|
|
</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 v-if="can('dashboard.orders_marketing')">
|
|
<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] chart-marketing">
|
|
<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" />
|
|
<VisTooltip :triggers="{ [donutSegmentSelector]: marketingTooltip }"
|
|
class-name="custom-tooltip" />
|
|
</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 v-if="can('dashboard.orders_status')">
|
|
<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] chart-status">
|
|
<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 ?? statusColors[0]"
|
|
:arc-width="30" />
|
|
<VisTooltip :triggers="{ [donutSegmentSelector]: statusTooltip }"
|
|
class-name="custom-tooltip" />
|
|
</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>
|
|
|
|
<AttendanceWebcamModal v-if="canCheckIn" v-model:open="webcamModalOpen" :mode="webcamMode" />
|
|
</AdminLayout>
|
|
</template>
|
|
|
|
<style>
|
|
:root {
|
|
--vis-tooltip-background-color: transparent !important;
|
|
--vis-tooltip-border-color: transparent !important;
|
|
--vis-tooltip-padding: 0 !important;
|
|
--vis-tooltip-box-shadow: none !important;
|
|
--vis-tooltip-border-radius: 0 !important;
|
|
}
|
|
|
|
.custom-tooltip {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
padding: 0 !important;
|
|
box-shadow: none !important;
|
|
border-radius: 0 !important;
|
|
}
|
|
|
|
.chart-channel,
|
|
.chart-payment,
|
|
.chart-marketing,
|
|
.chart-status {
|
|
--vis-donut-segment-stroke-width: 2;
|
|
--vis-donut-segment-stroke-color: #374151;
|
|
}
|
|
</style>
|