Files
antrean-operasi/pages/dashboard.vue
2026-02-23 10:09:13 +07:00

766 lines
30 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
// @ts-ignore: module has incompatible/undiscoverable typings in package exports
import VueApexCharts from "vue3-apexcharts";
import ModalPendaftaran from '@/components/pendaftaran/ModalPendaftaran.vue';
import api from '@/services/api';
// Filter bulan
const selectedMonth = ref(new Date().getMonth());
const selectedYear = ref(new Date().getFullYear());
const months = [
{ value: 0, text: 'Januari' },
{ value: 1, text: 'Februari' },
{ value: 2, text: 'Maret' },
{ value: 3, text: 'April' },
{ value: 4, text: 'Mei' },
{ value: 5, text: 'Juni' },
{ value: 6, text: 'Juli' },
{ value: 7, text: 'Agustus' },
{ value: 8, text: 'September' },
{ value: 9, text: 'Oktober' },
{ value: 10, text: 'November' },
{ value: 11, text: 'Desember' }
];
const yearNow = new Date().getFullYear();
const years = ref([
yearNow -3, yearNow - 2, yearNow -1,yearNow
]);
// Modal state
const showModal = ref(false);
// Loading state
const loadingStatusAntrian = ref(false);
const loadingKategoriAntrian = ref(false);
const loadingAntrianPerHari = ref(false);
const loadingAntrianPerSpesialis = ref(false);
const loadingAntrianPerSubspesialis = ref(false);
// Data status antrian dari API
const statusAntrianData = ref<Array<{ id: number; jumlah: number; statust: string }>>([]);
const kategoriAntrianData = ref<Array<{ id_kategori: number; kategori: string; jumlah: number }>>([]);
const antrianPerHariData = ref<Array<{ TanggalDaftar: string; Belum: number; Selesai: number; Tunda: number; Batal: number }>>([]);
// Fetch data perbandingan status antrian
const fetchStatusAntrian = async () => {
loadingStatusAntrian.value = true;
try {
const response = await api.get('/dashboard/perbandingan-status-antrian', {
params: {
year: selectedYear.value,
month: selectedMonth.value + 1 // API expects 1-12, not 0-11
}
});
if (response.data?.success && response.data?.data) {
statusAntrianData.value = response.data.data;
}
} catch (error) {
console.error('Error fetching status antrian:', error);
statusAntrianData.value = [];
} finally {
loadingStatusAntrian.value = false;
}
};
// Fetch data antrian per kategori
const fetchKategoriAntrian = async () => {
loadingKategoriAntrian.value = true;
try {
const response = await api.get('/dashboard/perbandingan-kategori-antrian', {
params: {
year: selectedYear.value,
month: selectedMonth.value + 1 // API expects 1-12, not 0-11
}
});
if (response.data?.success && response.data?.data) {
kategoriAntrianData.value = response.data.data;
}
} catch (error) {
console.error('Error fetching kategori antrian:', error);
kategoriAntrianData.value = [];
} finally {
loadingKategoriAntrian.value = false;
}
};
// Fetch data antrian per hari
const fetchAntrianPerHari = async () => {
loadingAntrianPerHari.value = true;
try {
const response = await api.get('/dashboard/antrian-per-hari', {
params: {
year: selectedYear.value,
month: selectedMonth.value + 1 // API expects 1-12, not 0-11
}
});
if (response.data?.success && response.data?.data) {
antrianPerHariData.value = response.data.data;
}
} catch (error) {
console.error('Error fetching antrian per hari:', error);
antrianPerHariData.value = [];
} finally {
loadingAntrianPerHari.value = false;
}
};
// Fetch data antrian per spesialis
const fetchAntrianPerSpesialis = async () => {
loadingAntrianPerSpesialis.value = true;
try {
const response = await api.get('/dashboard/table-antrian-per-spesialis', {
params: {
year: selectedYear.value,
month: selectedMonth.value + 1 // API expects 1-12, not 0-11
}
});
if (response.data?.success && response.data?.data) {
// Transform API data to match table headers (lowercase keys)
antrianPerSpesialis.value = response.data.data.map((item: any) => ({
spesialis: item.Spesialis,
total: item.Total,
belum: item.Belum,
selesai: item.Selesai,
tunda: item.Tunda,
batal: item.Batal
}));
}
} catch (error) {
console.error('Error fetching antrian per spesialis:', error);
antrianPerSpesialis.value = [];
} finally {
loadingAntrianPerSpesialis.value = false;
}
};
// Fetch data antrian per subspesialis
const fetchAntrianPerSubspesialis = async () => {
loadingAntrianPerSubspesialis.value = true;
try {
const response = await api.get('/dashboard/table-antrian-per-subspesialis', {
params: {
year: selectedYear.value,
month: selectedMonth.value + 1 // API expects 1-12, not 0-11
}
});
if (response.data?.success && response.data?.data) {
// Transform API data to match table headers (lowercase keys)
antrianPerSubspesialis.value = response.data.data.map((item: any) => ({
spesialis: item.Spesialis,
subspesialis: item.SubSpesialis,
total: item.Total,
belum: item.Belum,
selesai: item.Selesai,
tunda: item.Tunda,
batal: item.Batal
}));
}
} catch (error) {
console.error('Error fetching antrian per subspesialis:', error);
antrianPerSubspesialis.value = [];
} finally {
loadingAntrianPerSubspesialis.value = false;
}
};
// Computed properties untuk total masing-masing status
const totalBelum = computed(() => {
const item = statusAntrianData.value.find(d => d.statust === 'Belum');
return item?.jumlah || 0;
});
const totalSelesai = computed(() => {
const item = statusAntrianData.value.find(d => d.statust === 'Selesai');
return item?.jumlah || 0;
});
const totalTunda = computed(() => {
const item = statusAntrianData.value.find(d => d.statust === 'Tunda');
return item?.jumlah || 0;
});
const totalBatal = computed(() => {
const item = statusAntrianData.value.find(d => d.statust === 'Batal');
return item?.jumlah || 0;
});
const totalAll = computed(() => {
return totalBelum.value + totalSelesai.value + totalTunda.value + totalBatal.value;
});
// Buka modal pendaftaran
const openModal = () => {
showModal.value = true;
};
const handleModalSuccess = () => {
// Refresh data dashboard setelah pendaftaran berhasil
fetchStatusAntrian();
fetchKategoriAntrian();
fetchAntrianPerHari();
fetchAntrianPerSpesialis();
fetchAntrianPerSubspesialis();
console.log('Pendaftaran berhasil');
};
// Data dummy untuk antrian operasi berdasarkan status
// Dihapus karena sudah menggunakan API
// Data untuk Pie Chart - Status Antrian
const pieChartOptions = ref({
chart: {
type: 'pie',
height: 350
},
labels: ['Belum', 'Selesai', 'Tunda', 'Batal'],
colors: ['#5D87FF', '#13DEB9', '#FFAE1F', '#FA896B'],
legend: {
position: 'bottom'
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return Math.round(val) + '%'
}
},
responsive: [{
breakpoint: 480,
options: {
chart: {
width: 300
},
legend: {
position: 'bottom'
}
}
}]
});
const pieChartSeries = computed(() => {
return [
totalBelum.value,
totalSelesai.value,
totalTunda.value,
totalBatal.value
];
});
// Data untuk Pie Chart - Kategori Antrian (computed untuk dynamic labels)
const pieKategoriChartOptions = computed(() => ({
chart: {
type: 'pie',
height: 350
},
labels: kategoriAntrianData.value.map(item => item.kategori),
colors: ['#5D87FF', '#13DEB9', '#FFAE1F', '#FA896B', '#9C27B0', '#E91E63', '#00BCD4', '#4CAF50'],
legend: {
position: 'bottom'
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return Math.round(val) + '%'
}
},
responsive: [{
breakpoint: 480,
options: {
chart: {
width: 300
},
legend: {
position: 'bottom'
}
}
}]
}));
const pieKategoriChartSeries = computed(() => {
return kategoriAntrianData.value.map(item => item.jumlah);
});
// Data untuk Line Chart - Antrian Per Hari dalam 1 Bulan
const lineChartOptions = computed(() => {
const monthName = months[selectedMonth.value].text.substring(0, 3);
// Get categories from API data or generate default
const categories = antrianPerHariData.value.length > 0
? antrianPerHariData.value.map(item => {
const date = new Date(item.TanggalDaftar);
return `${date.getDate()} ${monthName}`;
})
: [];
return {
chart: {
type: 'line',
height: 350,
toolbar: {
show: true
},
zoom: {
enabled: true
}
},
colors: ['#5D87FF', '#13DEB9', '#FFAE1F', '#FA896B'],
dataLabels: {
enabled: false
},
stroke: {
curve: 'smooth',
width: 3
},
xaxis: {
categories: categories,
title: {
text: 'Tanggal'
}
},
yaxis: {
title: {
text: 'Jumlah Antrian'
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#e7e7e7',
row: {
colors: ['#f3f3f3', 'transparent'],
opacity: 0.5
}
},
tooltip: {
shared: true,
intersect: false
}
};
});
const lineChartSeries = computed(() => {
// If no data from API, return empty series
if (antrianPerHariData.value.length === 0) {
return [
{ name: 'Belum', data: [] },
{ name: 'Selesai', data: [] },
{ name: 'Tunda', data: [] },
{ name: 'Batal', data: [] }
];
}
// Map data from API
return [
{
name: 'Belum',
data: antrianPerHariData.value.map(item => item.Belum)
},
{
name: 'Selesai',
data: antrianPerHariData.value.map(item => item.Selesai)
},
{
name: 'Tunda',
data: antrianPerHariData.value.map(item => item.Tunda)
},
{
name: 'Batal',
data: antrianPerHariData.value.map(item => item.Batal)
}
];
});
// Data antrian per spesialis - populated from API
const antrianPerSpesialis = ref<Array<{ spesialis: string; total: number; belum: number; selesai: number; tunda: number; batal: number }>>([]);
// Data antrian per subspesialis - populated from API
const antrianPerSubspesialis = ref<Array<{ spesialis: string; subspesialis: string; total: number; belum: number; selesai: number; tunda: number; batal: number }>>([]);
const searchSpesialis = ref('');
const searchSubspesialis = ref('');
const headersSpesialis = [
{ title: 'Spesialis', key: 'spesialis' },
{ title: 'Total', key: 'total', align: 'center' as const },
{ title: 'Belum', key: 'belum', align: 'center' as const },
{ title: 'Selesai', key: 'selesai', align: 'center' as const },
{ title: 'Tunda', key: 'tunda', align: 'center' as const },
{ title: 'Batal', key: 'batal', align: 'center' as const }
];
const headersSubspesialis = [
{ title: 'Spesialis', key: 'spesialis' },
{ title: 'Subspesialis', key: 'subspesialis' },
{ title: 'Total', key: 'total', align: 'center' as const },
{ title: 'Belum', key: 'belum', align: 'center' as const },
{ title: 'Selesai', key: 'selesai', align: 'center' as const },
{ title: 'Tunda', key: 'tunda', align: 'center' as const },
{ title: 'Batal', key: 'batal', align: 'center' as const }
];
// Watch untuk refetch data saat bulan atau tahun berubah
watch([selectedMonth, selectedYear], () => {
fetchStatusAntrian();
fetchKategoriAntrian();
fetchAntrianPerHari();
fetchAntrianPerSpesialis();
fetchAntrianPerSubspesialis();
});
// Fetch data saat component mounted
onMounted(() => {
fetchStatusAntrian();
fetchKategoriAntrian();
fetchAntrianPerHari();
fetchAntrianPerSpesialis();
fetchAntrianPerSubspesialis();
});
definePageMeta({
middleware: 'auth',
pageTitle: 'Dashboard',
breadcrumbs: [{ text: 'Dashboard' }]
});
</script>
<template>
<v-row class="mb-4">
<v-col cols="12">
</v-col>
<v-col cols="12">
<v-card elevation="0" class="bg-transparent">
<v-card-item class="pa-0">
<div class="d-flex align-center justify-space-between flex-wrap ga-3">
<div class="d-flex align-center ga-3 flex-wrap">
<div class="d-flex align-center ga-2">
<v-select
v-model="selectedMonth"
:items="months"
item-title="text"
item-value="value"
density="compact"
variant="outlined"
hide-details
style="min-width: 140px;"
prepend-inner-icon="mdi-calendar-month"
></v-select>
<v-select
v-model="selectedYear"
:items="years"
density="compact"
variant="outlined"
hide-details
style="min-width: 100px;"
></v-select>
</div>
</div>
<v-btn
color="primary"
size="large"
elevation="2"
@click="openModal"
>
<Icon icon="solar:add-circle-bold" height="20" class="mr-2" />
Pendaftaran Operasi Baru
</v-btn>
</div>
</v-card-item>
</v-card>
</v-col>
</v-row>
<v-row>
<!-- Status Cards -->
<v-col cols="12" sm="6" md="3">
<v-card elevation="10" class="bg-primary" style="background: linear-gradient(135deg, #5D87FF 0%, #4570EA 100%);">
<v-card-item>
<div v-if="loadingStatusAntrian" class="d-flex ga-3 align-center">
<v-skeleton-loader type="avatar" class="bg-transparent white-skeleton"></v-skeleton-loader>
<div class="flex-grow-1">
<v-skeleton-loader type="text" class="bg-transparent white-skeleton"></v-skeleton-loader>
<v-skeleton-loader type="heading" class="bg-transparent white-skeleton"></v-skeleton-loader>
</div>
</div>
<div v-else class="d-flex ga-3 align-center">
<v-avatar size="56" class="rounded-md" style="background: rgba(255,255,255,0.2);">
<Icon icon="solar:hourglass-line-outline" class="text-white" height="28" />
</v-avatar>
<div>
<h6 class="text-subtitle-1 text-white opacity-80 mb-1">Belum</h6>
<h3 class="text-h2 font-weight-bold text-white">{{ totalBelum }}</h3>
</div>
</div>
</v-card-item>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card elevation="10" class="bg-success" style="background: linear-gradient(135deg, #13DEB9 0%, #06B89C 100%);">
<v-card-item>
<div v-if="loadingStatusAntrian" class="d-flex ga-3 align-center">
<v-skeleton-loader type="avatar" class="bg-transparent white-skeleton"></v-skeleton-loader>
<div class="flex-grow-1">
<v-skeleton-loader type="text" class="bg-transparent white-skeleton"></v-skeleton-loader>
<v-skeleton-loader type="heading" class="bg-transparent white-skeleton"></v-skeleton-loader>
</div>
</div>
<div v-else class="d-flex ga-3 align-center">
<v-avatar size="56" class="rounded-md" style="background: rgba(255,255,255,0.2);">
<Icon icon="solar:check-circle-outline" class="text-white" height="28" />
</v-avatar>
<div>
<h6 class="text-subtitle-1 text-white opacity-80 mb-1">Selesai</h6>
<h3 class="text-h2 font-weight-bold text-white">{{ totalSelesai }}</h3>
</div>
</div>
</v-card-item>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card elevation="10" class="bg-warning" style="background: linear-gradient(135deg, #FFAE1F 0%, #FF9800 100%);">
<v-card-item>
<div v-if="loadingStatusAntrian" class="d-flex ga-3 align-center">
<v-skeleton-loader type="avatar" class="bg-transparent white-skeleton"></v-skeleton-loader>
<div class="flex-grow-1">
<v-skeleton-loader type="text" class="bg-transparent white-skeleton"></v-skeleton-loader>
<v-skeleton-loader type="heading" class="bg-transparent white-skeleton"></v-skeleton-loader>
</div>
</div>
<div v-else class="d-flex ga-3 align-center">
<v-avatar size="56" class="rounded-md" style="background: rgba(255,255,255,0.2);">
<Icon icon="solar:pause-circle-outline" class="text-white" height="28" />
</v-avatar>
<div>
<h6 class="text-subtitle-1 text-white opacity-80 mb-1">Tunda</h6>
<h3 class="text-h2 font-weight-bold text-white">{{ totalTunda }}</h3>
</div>
</div>
</v-card-item>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card elevation="10" class="bg-error" style="background: linear-gradient(135deg, #FA896B 0%, #F06548 100%);">
<v-card-item>
<div v-if="loadingStatusAntrian" class="d-flex ga-3 align-center">
<v-skeleton-loader type="avatar" class="bg-transparent white-skeleton"></v-skeleton-loader>
<div class="flex-grow-1">
<v-skeleton-loader type="text" class="bg-transparent white-skeleton"></v-skeleton-loader>
<v-skeleton-loader type="heading" class="bg-transparent white-skeleton"></v-skeleton-loader>
</div>
</div>
<div v-else class="d-flex ga-3 align-center">
<v-avatar size="56" class="rounded-md" style="background: rgba(255,255,255,0.2);">
<Icon icon="solar:close-circle-outline" class="text-white" height="28" />
</v-avatar>
<div>
<h6 class="text-subtitle-1 text-white opacity-80 mb-1">Batal</h6>
<h3 class="text-h2 font-weight-bold text-white">{{ totalBatal }}</h3>
</div>
</div>
</v-card-item>
</v-card>
</v-col>
<!-- Pie Chart - Status Antrian -->
<v-col cols="12" lg="6">
<v-card elevation="10">
<v-card-item>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5 font-weight-bold">Perbandingan Status Antrean</h5>
<v-avatar size="40" class="rounded-md bg-lightprimary">
<Icon icon="solar:pie-chart-2-outline" class="text-primary" height="22" />
</v-avatar>
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<div v-if="loadingStatusAntrian" class="d-flex justify-center align-center" style="min-height: 350px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
<ClientOnly v-else>
<VueApexCharts
:key="`pie-status-${selectedMonth}-${selectedYear}`"
type="pie"
height="350"
:options="pieChartOptions"
:series="pieChartSeries"
></VueApexCharts>
</ClientOnly>
</v-card-text>
</v-card>
</v-col>
<!-- Pie Chart - Kategori Antrian -->
<v-col cols="12" lg="6">
<v-card elevation="10">
<v-card-item>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5 font-weight-bold">Perbandingan Kategori Antrean</h5>
<v-avatar size="40" class="rounded-md bg-lightsecondary">
<Icon icon="solar:pie-chart-2-outline" class="text-secondary" height="22" />
</v-avatar>
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<div v-if="loadingKategoriAntrian" class="d-flex justify-center align-center" style="min-height: 350px;">
<v-progress-circular indeterminate color="secondary" size="64"></v-progress-circular>
</div>
<ClientOnly v-else>
<VueApexCharts
:key="`pie-kategori-${selectedMonth}-${selectedYear}`"
type="donut"
height="350"
:options="pieKategoriChartOptions"
:series="pieKategoriChartSeries"
></VueApexCharts>
</ClientOnly>
</v-card-text>
</v-card>
</v-col>
<!-- Line Chart - Antrian Per Hari -->
<v-col cols="12" lg="12">
<v-card elevation="10">
<v-card-item>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5 font-weight-bold">Antrean Per Hari ({{ months[selectedMonth].text }} {{ selectedYear }})</h5>
<v-avatar size="40" class="rounded-md bg-lightsecondary">
<Icon icon="solar:chart-outline" class="text-secondary" height="22" />
</v-avatar>
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<div v-if="loadingAntrianPerHari" class="d-flex justify-center align-center" style="min-height: 350px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
<ClientOnly v-else>
<VueApexCharts
:key="`line-${selectedMonth}-${selectedYear}`"
type="line"
height="350"
:options="lineChartOptions"
:series="lineChartSeries"
></VueApexCharts>
</ClientOnly>
</v-card-text>
</v-card>
</v-col>
<!-- Table Antrian Per Spesialis -->
<v-col cols="12" lg="6">
<v-card elevation="10">
<v-card-item>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5 font-weight-bold">Antrean Per Spesialis</h5>
<v-avatar size="40" class="rounded-md bg-lightprimary">
<Icon icon="solar:users-group-rounded-outline" class="text-primary" height="22" />
</v-avatar>
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text class="pa-0">
<div v-if="loadingAntrianPerSpesialis" class="d-flex justify-center align-center pa-8" style="min-height: 300px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
<v-data-table v-else :headers="headersSpesialis" :items="antrianPerSpesialis" :search="searchSpesialis"
:items-per-page="10" class="border-0" hide-default-footer>
<template v-slot:item.total="{ item }">
<v-chip size="small" color="primary" variant="tonal">
{{ item.total }}
</v-chip>
</template>
<template v-slot:item.belum="{ item }">
<v-chip size="small" color="info" variant="tonal">
{{ item.belum }}
</v-chip>
</template>
<template v-slot:item.selesai="{ item }">
<v-chip size="small" color="success" variant="tonal">
{{ item.selesai }}
</v-chip>
</template>
<template v-slot:item.tunda="{ item }">
<v-chip size="small" color="warning" variant="tonal">
{{ item.tunda }}
</v-chip>
</template>
<template v-slot:item.batal="{ item }">
<v-chip size="small" color="error" variant="tonal">
{{ item.batal }}
</v-chip>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-col>
<!-- Table Antrian Per Subspesialis -->
<v-col cols="12" lg="6">
<v-card elevation="10">
<v-card-item>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5 font-weight-bold">Antrean Per Subspesialis</h5>
<v-avatar size="40" class="rounded-md bg-lightsecondary">
<Icon icon="solar:users-group-two-rounded-outline" class="text-secondary" height="22" />
</v-avatar>
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text class="pa-0">
<div v-if="loadingAntrianPerSubspesialis" class="d-flex justify-center align-center pa-8" style="min-height: 300px;">
<v-progress-circular indeterminate color="secondary" size="64"></v-progress-circular>
</div>
<v-data-table v-else :headers="headersSubspesialis" :items="antrianPerSubspesialis"
:search="searchSubspesialis" :items-per-page="10" class="border-0" hide-default-footer>
<template v-slot:item.total="{ item }">
<v-chip size="small" color="primary" variant="tonal">
{{ item.total }}
</v-chip>
</template>
<template v-slot:item.belum="{ item }">
<v-chip size="small" color="info" variant="tonal">
{{ item.belum }}
</v-chip>
</template>
<template v-slot:item.selesai="{ item }">
<v-chip size="small" color="success" variant="tonal">
{{ item.selesai }}
</v-chip>
</template>
<template v-slot:item.tunda="{ item }">
<v-chip size="small" color="warning" variant="tonal">
{{ item.tunda }}
</v-chip>
</template>
<template v-slot:item.batal="{ item }">
<v-chip size="small" color="error" variant="tonal">
{{ item.batal }}
</v-chip>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Modal Pendaftaran -->
<ModalPendaftaran
v-model="showModal"
mode="create"
@success="handleModalSuccess"
/>
</template>