pindah klinik belum service

This commit is contained in:
Fanrouver
2026-06-05 08:21:56 +07:00
parent 0928e78bec
commit f81dd57a16
9 changed files with 702 additions and 95 deletions
@@ -0,0 +1,156 @@
<template>
<v-dialog v-model="internalModel" max-width="500px">
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
<!-- Solid Blue Header -->
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
</v-card-title>
<v-card-text class="dialog-content-premium pa-6 text-center">
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
Antrean Belum Dibuat
</div>
<p class="text-body-1 mb-6">
Tiket antrean ruang atau penunjang <strong>belum dibuat</strong> untuk pasien ini.
</p>
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
<div class="comparison-item current">
<div class="item-label text-center">PASIEN AKTIF</div>
<div class="item-card">
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
<div class="item-status">Sedang Diproses</div>
</div>
</div>
</div>
<p class="text-body-2 text-neutral-600">
Apakah Anda yakin ingin menyelesaikan pelayanan untuk pasien ini?
</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 bg-light justify-center">
<v-btn
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
variant="outlined"
@click="internalModel = false"
size="large"
>
Batal
</v-btn>
<v-btn
class="px-8 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
variant="flat"
@click="confirm"
size="large"
elevation="2"
>
Ya, Selesaikan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
patient: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const internalModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const confirm = () => {
emit('confirm');
internalModel.value = false;
};
</script>
<style scoped>
.dialog-card {
/* Remove border to prevent white outline around blue header */
}
.bg-light {
background-color: var(--color-neutral-50) !important;
}
.comparison-container {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-neutral-300);
}
.comparison-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-label {
font-size: 10px;
font-weight: 800;
color: var(--color-neutral-600);
letter-spacing: 0.05em;
}
.item-card {
background: white;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--color-neutral-400);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.item-number {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.item-status {
font-size: 11px;
color: var(--color-neutral-600);
font-weight: 600;
}
.text-danger-700 {
color: var(--color-danger-700) !important;
}
.btn-batal {
border-color: var(--color-neutral-300) !important;
color: var(--color-neutral-700) !important;
}
.btn-confirm {
background-color: var(--color-primary-600) !important;
color: white !important;
}
</style>
+11 -9
View File
@@ -34,15 +34,6 @@
</div>
</div>
<div class="header-right d-flex align-center" style="gap: 8px;">
<v-chip
v-if="patient.ruang"
color="primary-600"
variant="outlined"
size="small"
class="subspesialis-chip text-caption font-weight-bold"
>
{{ patient.ruang }}
</v-chip>
<v-chip
:color="getStatusColor(patient.status)"
size="small"
@@ -63,6 +54,17 @@
>
{{ patient.klinik }}
</v-chip>
<!-- Sub Spesialis -->
<v-chip
v-if="patient.ruang"
color="primary-600"
variant="outlined"
size="small"
class="subspesialis-chip font-weight-bold"
style="height: 24px; font-size: 11px;"
>
{{ patient.ruang }}
</v-chip>
<!-- Tipe Layanan atau Jenis Pasien -->
<v-chip
v-if="displayJenisPasien"
@@ -487,7 +487,11 @@ const handleAction = (patient, action) => {
padding: 8px 12px;
background: var(--color-neutral-200);
border-radius: 8px;
display: inline-flex;
align-items: center;
border: 1px solid var(--color-neutral-400);
border-left: 3px solid var(--color-primary-600);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.results-text {
@@ -0,0 +1,157 @@
<template>
<v-dialog v-model="internalModel" max-width="500px">
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
<!-- Solid Blue Header -->
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
</v-card-title>
<v-card-text class="dialog-content-premium pa-6 text-center">
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
Tindakan Diperlukan
</div>
<p class="text-body-1 mb-6">
Pasien ini <strong>sudah dibuatkan</strong> tiket antrean ruang atau penunjang, namun statusnya belum diselesaikan di Loket.
</p>
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
<div class="comparison-item current">
<div class="item-label text-center">PASIEN AKTIF</div>
<div class="item-card">
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
<div class="item-status">Menunggu Selesai</div>
</div>
</div>
</div>
<p class="text-body-2 text-neutral-600">
Apakah Anda ingin menyelesaikan pasien ini sekarang dan melanjutkan ke antrean berikutnya?
</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 bg-light justify-center">
<v-btn
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
variant="outlined"
@click="internalModel = false"
size="large"
>
Batal
</v-btn>
<v-btn
class="px-6 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
variant="flat"
@click="confirm"
size="large"
elevation="2"
>
Ya, Selesaikan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
patient: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const internalModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const confirm = () => {
emit('confirm');
internalModel.value = false;
};
</script>
<style scoped>
.dialog-card {
/* No border to prevent white outline */
}
.bg-light {
background-color: var(--color-neutral-50) !important;
}
.comparison-container {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-neutral-300);
}
.comparison-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-label {
font-size: 10px;
font-weight: 800;
color: var(--color-neutral-600);
letter-spacing: 0.05em;
}
.item-card {
background: white;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--color-neutral-400);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.item-number {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.item-status {
font-size: 11px;
color: var(--color-neutral-600);
font-weight: 600;
}
.text-danger-700 {
color: var(--color-danger-700) !important;
}
.btn-batal {
border-color: var(--color-neutral-300) !important;
color: var(--color-neutral-700) !important;
}
.btn-confirm {
background-color: var(--color-primary-600) !important;
color: white !important;
}
</style>
+17 -10
View File
@@ -126,18 +126,25 @@ export const useThermalPrint = () => {
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
const noAntrianDisplay = data.noAntrian.split(' |')[0];
// Format informasi ruang: "Poli Anak - Ruang A"
// Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi"
let ruangInfo = '';
if (data.klinik && data.nomorRuang) {
// Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst)
const ruangNumber = parseInt(data.nomorRuang) || 1;
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
} else if (data.klinik && data.ruang) {
// Fallback jika hanya ada nama ruang
// 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis)
if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) {
ruangInfo = `${data.klinik} - ${data.ruang}`;
} else if (data.klinik) {
// Hanya klinik jika tidak ada info ruang
}
// 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll)
else if (data.klinik && data.nomorRuang) {
const ruangNumber = parseInt(data.nomorRuang) || 1;
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A'
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
}
// 3. Fallback: gunakan data.ruang yang ada
else if (data.klinik && data.ruang) {
ruangInfo = `${data.klinik} - ${data.ruang}`;
}
// 4. Default: hanya nama klinik
else if (data.klinik) {
ruangInfo = data.klinik;
}
+132 -33
View File
@@ -304,6 +304,20 @@
</v-card>
</v-dialog>
<!-- Check-In Confirmation Dialog -->
<CheckInConfirmationDialog
v-model="showCheckInConfirmDialog"
:patient="currentProcessingPatient"
@confirm="confirmCheckIn"
/>
<!-- Unfinished Patient Dialog -->
<UnfinishedPatientDialog
v-model="showUnfinishedPatientDialog"
:patient="currentProcessingPatient"
@confirm="confirmUnfinishedAndProcess"
/>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
@@ -327,6 +341,8 @@ import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import CheckInConfirmationDialog from "@/components/features/queue/CheckInConfirmationDialog.vue";
import UnfinishedPatientDialog from "@/components/features/queue/UnfinishedPatientDialog.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
import { useThermalPrint } from "@/composables/useThermalPrint";
@@ -387,8 +403,11 @@ const {
const changeKlinik = async (klinik) => {
const result = await originalChangeKlinik(klinik);
if (result.success) {
// Global broadcast to update all displays and other admins
broadcastUpdate();
// If patient was moved to a different loket, include destination loket in broadcast
const broadcastData = result.moved && result.patient?.loketId
? { loketId: result.patient.loketId } // Destination loket will get notified
: null;
broadcastUpdate(broadcastData);
}
};
@@ -569,10 +588,10 @@ const selectedFastTrack = ref(null);
// Dialog Klinik Ruang
const showKlinikRuangDialog = ref(false);
const klinikRuangSearch = ref("");
const activePanel = ref(null); // Tracks the open Klinik Ruang panel
// Confirmation Replace Dialog
const activePanel = ref(null); // Additional Refs
const showConfirmReplaceDialog = ref(false);
const showCheckInConfirmDialog = ref(false);
const showUnfinishedPatientDialog = ref(false);
const pendingReplaceItem = ref(null);
const pendingReplaceAction = ref(null);
@@ -887,11 +906,38 @@ const nextQueueInfo = computed(() => {
const handlePatientAction = async (action) => {
if (currentProcessingPatient.value) {
if (action === 'check-in') {
// Periksa apakah tiket antrean ruang atau penunjang sudah dibuat
let allPatientsArray = [];
if (queueStore.allPatients) {
allPatientsArray = Array.isArray(queueStore.allPatients)
? queueStore.allPatients
: (queueStore.allPatients.value || []);
}
const hasGeneratedNextQueue = allPatientsArray.some(p =>
(p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) ||
(p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian)
);
if (!hasGeneratedNextQueue) {
showCheckInConfirmDialog.value = true;
return; // Hentikan proses, tunggu konfirmasi user
}
}
await processPatient(currentProcessingPatient.value, action);
broadcastUpdate();
}
};
const confirmCheckIn = async () => {
if (currentProcessingPatient.value) {
await processPatient(currentProcessingPatient.value, 'check-in');
broadcastUpdate();
}
};
const handleCall = async (count) => {
if (count === 1) {
callNext(loketId.value);
@@ -932,14 +978,44 @@ const handleProcessNext = () => {
const confirmAndProcess = (item, action) => {
if (currentProcessingPatient.value) {
let allPatientsArray = [];
if (queueStore.allPatients) {
allPatientsArray = Array.isArray(queueStore.allPatients)
? queueStore.allPatients
: (queueStore.allPatients.value || []);
}
const hasGeneratedNextQueue = allPatientsArray.some(p =>
(p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) ||
(p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian)
);
pendingReplaceItem.value = item;
pendingReplaceAction.value = action;
showConfirmReplaceDialog.value = true;
if (hasGeneratedNextQueue) {
showUnfinishedPatientDialog.value = true;
} else {
showConfirmReplaceDialog.value = true;
}
} else {
executeProcess(item, action);
}
};
const confirmUnfinishedAndProcess = async () => {
if (currentProcessingPatient.value) {
// Selesaikan pasien aktif
await processPatient(currentProcessingPatient.value, 'check-in');
// Proses pasien berikutnya / yang dipilih
executeProcess(pendingReplaceItem.value, pendingReplaceAction.value);
pendingReplaceItem.value = null;
pendingReplaceAction.value = null;
}
};
const executeProcess = async (item, action) => {
if (action === "next") {
processNextQueue();
@@ -966,47 +1042,70 @@ const handleConfirmReplace = () => {
const { sendViaPost } = queueStore;
const broadcastUpdate = async (callData = null) => {
// Debounce timer to prevent multiple rapid broadcasts
let _broadcastDebounceTimer = null;
const broadcastUpdate = async (extraData = null) => {
// extraData can be:
// - null: regular refresh (debounced)
// - { noAntrian, ... }: a CALL event (immediate, bypass debounce)
// - { loketId }: a MOVE event (immediate, bypass debounce, notify destination)
const isCallEvent = extraData && extraData.noAntrian; // Patient call
const isMoveEvent = extraData && extraData.loketId && !extraData.noAntrian; // Klinik move
const isImmediate = isCallEvent || isMoveEvent;
// Debounce only plain refresh events
if (!isImmediate) {
if (_broadcastDebounceTimer) return; // Already pending, skip
_broadcastDebounceTimer = setTimeout(() => {
_broadcastDebounceTimer = null;
}, 1500);
}
try {
const displayClientIds = [];
// Base broadcast IDs for this loket
displayClientIds.push(`anjungan-loket-${loketId.value}`);
displayClientIds.push(`anjungan-masuk-${loketId.value}`);
// Also broadcast to clinic specific displays if needed
// displayClientIds.push(`anjungan-klinik-${...}`);
// Broadcast to ALL loket admins and anjungans (including this one, for other computers opening the same loket)
for (let i = 1; i <= 10; i++) {
displayClientIds.push(`admin-loket-${i}`);
displayClientIds.push(`anjungan-loket-${i}`);
displayClientIds.push(`anjungan-masuk-${i}`);
}
console.log('📡 [AdminLoket] Broadcasting update trigger to:', displayClientIds);
const payload = {
loketId: loketId.value,
triggerRefresh: true
};
// If this is a CALL event, include the patient data
if (callData) {
if (isCallEvent) {
payload.callEvent = {
...callData,
...extraData,
loketId: loketId.value,
loketName: loketName.value,
calledAt: Date.now()
};
}
// Send all broadcasts in parallel for speed
await Promise.all(displayClientIds.map(clientId =>
sendViaPost({
to_client: clientId,
data: payload
})
));
// Minimal broadcast: only send to the most relevant clients
const criticalTargets = [
`anjungan-loket-${loketId.value}`,
`anjungan-masuk-${loketId.value}`,
`admin-loket-${loketId.value}`,
];
// For MOVE events, also notify the destination loket
if (isMoveEvent && extraData.loketId && String(extraData.loketId) !== String(loketId.value)) {
const destLoketId = extraData.loketId;
criticalTargets.push(`admin-loket-${destLoketId}`);
criticalTargets.push(`anjungan-loket-${destLoketId}`);
criticalTargets.push(`anjungan-masuk-${destLoketId}`);
console.log(`📡 [AdminLoket] Patient moved: also notifying Loket ${destLoketId}`);
}
console.log('📡 [AdminLoket] Broadcasting to:', criticalTargets);
// Send sequentially with a delay to avoid 429 Rate Limit
for (const clientId of criticalTargets) {
try {
await sendViaPost({ to_client: clientId, data: payload });
} catch (err) {
console.warn(`Failed to broadcast to ${clientId}:`, err?.message || err);
}
await new Promise(resolve => setTimeout(resolve, 80));
}
} catch (error) {
console.error('❌ [AdminLoket] Error broadcasting update:', error);
}
+100 -33
View File
@@ -240,11 +240,18 @@
<div class="chart-container">
<ClientOnly>
<Line
v-if="visitTrendData.labels && visitTrendData.labels.length > 0"
v-if="visitTrendData.labels && visitTrendData.labels.length > 0 && visitTrendData.datasets[0].data.length > 0 && visitTrendData.datasets[0].data.some(v => v > 0)"
:data="visitTrendData"
:options="areaChartOptions"
class="chart-wrapper"
/>
<div v-else class="empty-chart-state">
<div class="empty-state-content">
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-chart-timeline-variant</v-icon>
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
<p class="text-caption text-grey">Tren kunjungan bulanan akan tampil di sini</p>
</div>
</div>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
@@ -285,11 +292,18 @@
<div class="chart-container">
<ClientOnly>
<Doughnut
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0"
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0 && paymentStatusData.datasets[0].data.length > 0 && paymentStatusData.datasets[0].data.some(v => v > 0)"
:data="paymentStatusData"
:options="doughnutOptions"
class="chart-wrapper pie-chart"
/>
<div v-else class="empty-chart-state">
<div class="empty-state-content">
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-chart-donut-variant</v-icon>
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
<p class="text-caption text-grey">Distribusi metode bayar akan tampil di sini</p>
</div>
</div>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="success" size="50"></v-progress-circular>
@@ -330,11 +344,18 @@
<div class="chart-container">
<ClientOnly>
<Bar
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0"
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0 && waitingTimeData.datasets[0].data.length > 0 && waitingTimeData.datasets[0].data.some(v => v > 0)"
:data="waitingTimeData"
:options="horizontalBarOptions"
class="chart-wrapper"
/>
<div v-else class="empty-chart-state">
<div class="empty-state-content">
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-clock-outline</v-icon>
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
<p class="text-caption text-grey">Data waktu tunggu poli akan tampil di sini</p>
</div>
</div>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="secondary" size="50"></v-progress-circular>
@@ -379,11 +400,18 @@
<div class="chart-container">
<ClientOnly>
<PolarArea
v-if="attendanceData.labels && attendanceData.labels.length > 0"
v-if="attendanceData.labels && attendanceData.labels.length > 0 && attendanceData.datasets[0].data.length > 0 && attendanceData.datasets[0].data.some(v => v > 0)"
:data="attendanceData"
:options="polarAreaOptions"
class="chart-wrapper pie-chart"
/>
<div v-else class="empty-chart-state">
<div class="empty-state-content">
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-account-check</v-icon>
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
<p class="text-caption text-grey">Statistik tingkat kehadiran akan tampil di sini</p>
</div>
</div>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
@@ -499,54 +527,55 @@ const exportOptions = ref([
const currentDate = ref('');
const filterDateFrom = ref(dayjs().format('YYYY-MM-DD'));
const filterDateTo = ref(dayjs().format('YYYY-MM-DD'));
const selectedYear = ref(2025);
const availableYears = ref([2025, 2026, 2027]);
const currentYear = dayjs().year();
const selectedYear = ref(currentYear);
const availableYears = ref([currentYear - 1, currentYear, currentYear + 1]);
// New Stats Data - Updated Metrics
const stats = ref([
{
label: 'Pasien Hari Ini',
value: '324',
value: '-',
icon: 'mdi-account-heart',
iconSize: 24,
iconColor: 'white',
color: 'primary',
change: '+18.2%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
change: '-',
changeType: 'neutral',
changeIcon: 'mdi-minus'
},
{
label: 'Antrean Aktif',
value: '47',
value: '-',
icon: 'mdi-clock-fast',
iconSize: 24,
iconColor: 'white',
color: 'secondary',
change: '+5',
changeType: 'positive',
changeIcon: 'mdi-arrow-up'
change: '-',
changeType: 'neutral',
changeIcon: 'mdi-minus'
},
{
label: 'Rata-rata Tunggu',
value: '12 min',
value: '-',
icon: 'mdi-timer-outline',
iconSize: 24,
iconColor: 'white',
color: 'success',
change: '-3 min',
changeType: 'positive',
changeIcon: 'mdi-trending-down'
change: '-',
changeType: 'neutral',
changeIcon: 'mdi-minus'
},
{
label: 'Tingkat Kehadiran',
value: '89.5%',
value: '-',
icon: 'mdi-check-circle',
iconSize: 24,
iconColor: 'white',
color: 'primary',
change: '+2.3%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
change: '-',
changeType: 'neutral',
changeIcon: 'mdi-minus'
}
]);
@@ -556,7 +585,7 @@ const visitTrendData = ref({
datasets: [
{
label: 'Pasien Umum',
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
data: [],
backgroundColor: 'rgba(86, 126, 231, 0.2)',
borderColor: colors.primary[500],
borderWidth: 3,
@@ -570,7 +599,7 @@ const visitTrendData = ref({
},
{
label: 'Pasien BPJS',
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
data: [],
backgroundColor: 'rgba(255, 132, 65, 0.2)',
borderColor: colors.secondary[500],
borderWidth: 3,
@@ -587,10 +616,10 @@ const visitTrendData = ref({
// Payment Status Data (Doughnut Chart)
const paymentStatusData = ref({
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
labels: [],
datasets: [
{
data: [1850, 680, 320, 280],
data: [],
backgroundColor: [
colors.primary[500],
colors.secondary[500],
@@ -606,11 +635,11 @@ const paymentStatusData = ref({
// Waiting Time per Poli Data (Horizontal Bar Chart)
const waitingTimeData = ref({
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
labels: [],
datasets: [
{
label: 'Waktu Tunggu (menit)',
data: [15, 12, 8, 18, 10, 22],
data: [],
backgroundColor: [
colors.primary[400],
colors.secondary[400],
@@ -635,10 +664,10 @@ const waitingTimeData = ref({
// Attendance Data (Polar Area Chart)
const attendanceData = ref({
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
labels: [],
datasets: [
{
data: [1850, 420, 280, 150, 230],
data: [],
backgroundColor: [
colors.success[400],
colors.primary[400],
@@ -998,8 +1027,24 @@ const refreshDashboardStats = async (filterParams = {}) => {
// 4. Update Monthly Trend (Map API into datasets)
if (data.monthly_trend && data.monthly_trend.length > 0) {
// Logic to update trend charts can be expanded here
// For now, we'll keep the existing structure but show where real data fits
// TODO: Map 'data.monthly_trend' to 'visitTrendData'
// Example:
// visitTrendData.value.labels = data.monthly_trend.map(m => m.month_name);
// visitTrendData.value.datasets[0].data = data.monthly_trend.map(m => m.umum_count);
// visitTrendData.value.datasets[1].data = data.monthly_trend.map(m => m.bpjs_count);
}
// 5. Update Attendance Data
if (data.attendance_stats && Object.keys(data.attendance_stats).length > 0) {
// TODO: Map 'data.attendance_stats' to 'attendanceData'
// Example:
// attendanceData.value = {
// labels: Object.keys(data.attendance_stats),
// datasets: [{
// ...attendanceData.value.datasets[0],
// data: Object.values(data.attendance_stats)
// }]
// };
}
}
} catch (error) {
@@ -1551,7 +1596,7 @@ const downloadBlob = (blob, filename) => {
/* Compact Stats Cards */
.stats-row {
margin-top: -20px;
margin-top: 8px;
margin-bottom: 12px !important;
position: relative;
z-index: 3;
@@ -2082,6 +2127,28 @@ const downloadBlob = (blob, filename) => {
}
}
.empty-chart-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 200px;
background: rgba(255, 255, 255, 0.4);
border-radius: 12px;
border: 1px dashed rgba(0, 0, 0, 0.1);
margin: 16px;
padding: 24px;
.empty-state-content {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
}
.live-chip {
font-weight: 700;
font-size: 11px;
+54
View File
@@ -0,0 +1,54 @@
import { defineEventHandler, readBody } from 'h3';
import fs from 'fs';
import path from 'path';
// Path to the mock JSON file
const filePath = path.resolve(process.cwd(), 'public/data/patient_subspesialis.json');
// Ensure directory and file exist
const ensureFileExists = () => {
const dirPath = path.dirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8');
}
};
export default defineEventHandler(async (event) => {
ensureFileExists();
if (event.node.req.method === 'GET') {
// Return all mappings
try {
const data = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(data);
} catch (e) {
return {};
}
}
if (event.node.req.method === 'POST') {
// Save a new mapping
try {
const body = await readBody(event);
const { barcode, subspesialis } = body;
if (!barcode || !subspesialis) {
return { success: false, message: 'Barcode and subspesialis are required' };
}
const fileData = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(fileData);
// Save mapping: barcode -> subspesialis object
data[barcode] = subspesialis;
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
return { success: true, message: 'Saved successfully' };
} catch (e) {
return { success: false, message: e.message };
}
}
});
+71 -10
View File
@@ -784,10 +784,29 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
const patientsRaw = rawData.data || [];
// Fetch temporary subspesialis mapping
let subspesialisMap = {};
try {
const subRes = await fetch('/api/patient-subspesialis');
if (subRes.ok) {
subspesialisMap = await subRes.json();
}
} catch (e) {
console.error('Failed to fetch temporary subspesialis mapping', e);
}
// Map API data to store format
const mappedPatients = patientsRaw.map((apiPatient, index) =>
mapApiPatientToStoreFormat(apiPatient, index)
).filter(p => isTodayPatient(p));
const mappedPatients = patientsRaw.map((apiPatient, index) => {
const mapped = mapApiPatientToStoreFormat(apiPatient, index);
// Merge temporary subspesialis mapping
const barcodeKey = mapped.barcode || mapped.idtiket;
if (barcodeKey && subspesialisMap[barcodeKey]) {
mapped.ruang = subspesialisMap[barcodeKey].namaRuang || subspesialisMap[barcodeKey].nama;
mapped.nomorRuang = subspesialisMap[barcodeKey].nomorRuang || subspesialisMap[barcodeKey].ruang;
mapped.kodeRuang = subspesialisMap[barcodeKey].kodeRuang || subspesialisMap[barcodeKey].id;
}
return mapped;
}).filter(p => isTodayPatient(p));
// Deduplicate patients by idtiket
// API returns multiple entries if patient is at multiple positions
@@ -891,6 +910,25 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
return true; // Keep the currently processing patient
}
// CRITICAL FIX: Protect patients manually moved to a different loket via changeKlinik.
// The backend API doesn't know about this move yet, so API refresh would wipe the patient.
// We protect them for 10 minutes (600000ms) to give admin time to process them.
if (p.manuallyMoved && p.movedAt) {
const timeSinceMoved = Date.now() - p.movedAt;
const tenMinutes = 10 * 60 * 1000;
if (timeSinceMoved < tenMinutes) {
console.log(`🛡️ [queueStore] Protecting manually-moved patient ${p.noAntrian?.split(' |')[0]} (moved ${Math.round(timeSinceMoved/1000)}s ago) from API overwrite`);
// Prevent the old loket's API data from overwriting this patient
if (newPatientMap.has(key)) {
newPatientMap.delete(key);
}
return true; // Keep the moved patient
} else {
// After 10 minutes, allow API to take over
console.log(`⏰ [queueStore] manuallyMoved patient ${p.noAntrian?.split(' |')[0]} protection expired. Allowing API overwrite.`);
}
}
// If it's still in loket stage but marked as processed, and it's in the API batch,
// we might be experiencing API lag. Keep it as processed.
if (p.status === 'processed' && newPatientMap.has(key)) {
@@ -2297,7 +2335,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
if (newPatient.kodeKlinik) {
const allLokets = loketStore.lokets || [];
const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newPatient.kodeKlinik)
l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
);
if (targetLoket) {
newPatient.loketId = targetLoket.id;
@@ -2672,7 +2711,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
const getPatientsByKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan = null) => {
return computed(() => {
let patients = allPatients.value.filter(p =>
p.kodeKlinik === kodeKlinik &&
(p.kodeKlinik === kodeKlinik || (p.kodeKlinik && p.kodeKlinik.split('-')[0] === kodeKlinik)) &&
p.nomorRuang === nomorRuang &&
p.processStage === 'klinik-ruang'
);
@@ -2694,7 +2733,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// Call next patient for a specific room and tipeLayanan
const callNextKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan, allowMultiple = false) => {
const patients = allPatients.value.filter(p =>
p.kodeKlinik === kodeKlinik &&
(p.kodeKlinik === kodeKlinik || (p.kodeKlinik && p.kodeKlinik.split('-')[0] === kodeKlinik)) &&
p.nomorRuang === nomorRuang &&
p.tipeLayanan === tipeLayanan &&
p.processStage === 'klinik-ruang' &&
@@ -2854,7 +2893,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
if (newKlinik.kode) {
const allLokets = loketStore.lokets || [];
const foundLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newKlinik.kode)
l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0]))
);
if (foundLoket) {
@@ -2881,13 +2921,16 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
}
// Update the patient in allPatients
// Mark as manuallyMoved so API refresh doesn't wipe this patient from the destination loket
const updatedPatient = {
...oldPatient,
klinik: newKlinik.name,
kodeKlinik: newKlinik.kode,
loketId: targetLoketId,
loket: targetLoketName,
status: newStatus
status: newStatus,
manuallyMoved: isMovedToDifferentLoket ? true : (oldPatient.manuallyMoved || false),
movedAt: isMovedToDifferentLoket ? Date.now() : (oldPatient.movedAt || null)
};
allPatients.value[patientIndex] = updatedPatient;
@@ -3022,7 +3065,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
if (!newPatient.loketId && newPatient.kodeKlinik) {
const allLokets = loketStore.lokets || [];
const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newPatient.kodeKlinik)
l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
);
if (targetLoket) {
@@ -3061,7 +3105,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// Find loket that handles BOTH the clinic AND the payment type
const targetLoket = allLokets.find(l => {
if (l.source !== 'api' || l.id >= 1000) return false;
const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(clinic.kode);
const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(clinic.kode) || l.pelayanan.includes(clinic.kode?.split('-')[0]));
if (handlesClinic) {
return isPaymentCompatible(paymentTypeForMatching, l.pembayaran);
}
@@ -3179,6 +3224,22 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
allPatients.value.push(newPatient);
// NOTE: Kita tidak perlu incrementBarcodeCounter() di sini karena barcode berasal dari API
// Temporary: Save subSpesialis mapping to local JSON API
if (subSpesialis) {
try {
await fetch('/api/patient-subspesialis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
barcode: barcode,
subspesialis: subSpesialis
})
});
} catch (e) {
console.error('Failed to save temporary subSpesialis mapping', e);
}
}
return {
success: true,