Files
web-antrean/pages/CheckInPasien/checkIn.vue.refactore.backup
T
2026-01-05 08:32:59 +07:00

717 lines
25 KiB
Plaintext

<template>
<v-app class="bg-modern">
<v-main class="no-overflow">
<v-container fluid class="no-scroll-container pa-2 pa-md-4">
<v-row align="start" justify="center" class="fill-height">
<v-col cols="12" sm="11" md="10" lg="8" xl="7" class="d-flex flex-column">
<!-- Main Card dengan Glassmorphism -->
<v-card class="main-card" elevation="0">
<!-- Header Minimalis -->
<div class="header-modern">
<div class="header-content">
<div class="icon-circle">
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="title-modern">Check-in Pasien</h1>
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
</div>
</div>
<!-- Tabs Minimalis -->
<v-tabs
v-model="tab"
align-tabs="center"
class="tabs-modern mt-3"
bg-color="transparent"
slider-color="#FB8C00"
height="40"
>
<v-tab value="scan" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
<span>Scan QR</span>
</v-tab>
<v-tab value="manual" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
<span>Manual</span>
</v-tab>
<v-tab value="generate" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
<span>Generate QR</span>
</v-tab>
</v-tabs>
</div>
<v-card-text class="content-modern pa-4 pa-md-6">
<v-window v-model="tab">
<!-- Tab Scan QR -->
<v-window-item value="scan">
<QRScanTab
:is-scanning="isScanning"
:has-camera="hasCamera"
:camera-checking="cameraChecking"
:camera-ready="cameraReady"
:primary-color="primaryColor"
@start-scanning="startScanning"
@stop-scanning="stopScanning"
@test-camera="testCamera"
@open-history="openHistoryDialog"
@open-qr-history="openQRHistoryDialog"
/>
</v-window-item>
<!-- Tab Manual -->
<v-window-item value="manual">
<ManualInputTab
v-model="manualInput"
:primary-color="primaryColor"
:secondary-color="secondaryColor"
@submit="checkInManual"
@open-history="openHistoryDialog"
ref="manualInputTabRef"
/>
</v-window-item>
<!-- Tab Generate QR -->
<v-window-item value="generate">
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Generate QR Code untuk Testing</h3>
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
</div>
</div>
<!-- Quick Preset Buttons -->
<div class="mb-3">
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
<v-row dense>
<v-col cols="6">
<v-btn
variant="outlined"
color="success"
size="small"
@click="generateQuickQR(generateRandomPatientId(), 'ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-check-circle</v-icon>
Test ALLOWED
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
variant="outlined"
color="warning"
size="small"
@click="generateQuickQR(generateRandomPatientId(), 'NOT_ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-clock-alert</v-icon>
Test NOT_ALLOWED
</v-btn>
</v-col>
</v-row>
</div>
<!-- Form Generate -->
<v-form @submit.prevent="generateQRCode">
<v-text-field
v-model="generatePatientId"
label="ID Pasien"
placeholder="Contoh: P-123456"
prepend-inner-icon="mdi-identifier"
variant="outlined"
:color="primaryColor"
class="input-modern mb-3"
density="comfortable"
clearable
hide-details="auto"
>
<template v-slot:append-inner>
<v-btn
icon
size="small"
variant="text"
@click="generateRandomId()"
title="Generate Random ID"
>
<v-icon size="20">mdi-refresh</v-icon>
</v-btn>
</template>
</v-text-field>
<v-select
v-model="generateStatus"
label="Status Check-in"
:items="statusOptions"
prepend-inner-icon="mdi-shield-check"
variant="outlined"
:color="primaryColor"
class="input-modern mb-4"
density="comfortable"
hide-details="auto"
></v-select>
<div class="d-flex justify-center">
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
:disabled="!generatePatientId"
>
<v-icon start size="20">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
</div>
</v-form>
<!-- QR Code Display -->
<div v-if="generatedQRData" class="qr-display mt-6">
<v-card variant="outlined" class="pa-4">
<div class="text-center">
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
<div id="qrcode" class="qr-code-container mb-4"></div>
<v-chip :color="generateStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
<v-icon start>{{ generateStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
{{ generateStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
</v-chip>
<p class="text-body-2 text-grey mb-4">
Data: {{ generatedQRData }}
</p>
<!-- Action Buttons -->
<v-row dense>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="downloadQR"
class="text-none"
>
<v-icon start size="18">mdi-download</v-icon>
Download
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="copyQRToClipboard"
class="text-none"
>
<v-icon start size="18">mdi-content-copy</v-icon>
Copy
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="shareQR"
class="text-none"
>
<v-icon start size="18">mdi-share-variant</v-icon>
Share
</v-btn>
</v-col>
</v-row>
</div>
</v-card>
<!-- Instructions -->
<v-alert
type="success"
variant="tonal"
class="mt-4 text-body-2"
>
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
<strong>Cara menggunakan untuk Testing:</strong>
<ol class="ml-4 mt-2">
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
</ol>
</v-alert>
</div>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<!-- Stats Footer Minimalis -->
<div class="stats-footer-modern mt-3">
<v-row dense>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
</div>
<div class="stat-value">{{ todayCount }}</div>
<div class="stat-label">Hari Ini</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon color="success" size="20">mdi-check-circle</v-icon>
</div>
<div class="stat-value">{{ completedCount }}</div>
<div class="stat-label">Selesai</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
</div>
<div class="stat-value">{{ pendingCount }}</div>
<div class="stat-label">Menunggu</div>
</div>
</v-col>
</v-row>
</div>
</v-col>
</v-row>
</v-container>
</v-main>
<!-- Check-in Result Dialog -->
<CheckInDialog
v-model="infoDialog"
:last-check-in-result="lastCheckInResult"
:info-message="infoMessage"
:info-action="infoAction"
:scanned-data="scannedData"
@close="handleInfoAction"
/>
<!-- Enhanced Snackbar -->
<v-snackbar
v-model="snackbarShow"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="bottom right"
rounded="pill"
elevation="24"
class="custom-snackbar"
>
<div class="d-flex align-center">
<v-avatar :color="snackbar.color" size="32" class="mr-3">
<v-icon size="20" color="white">{{ snackbar.icon }}</v-icon>
</v-avatar>
<div>
<div class="font-weight-bold">{{ snackbar.title }}</div>
<div class="text-body-2">{{ snackbar.message }}</div>
</div>
</div>
</v-snackbar>
<!-- History Dialog -->
<HistoryDialog
v-model="historyDialog"
:search="historySearch"
:date-filter="historyDateFilter"
:status-filter="historyStatusFilter"
:filtered-history="filteredHistory"
:history="checkInHistory"
:status-options="historyStatusOptions"
:primary-color="primaryColor"
:get-status-color="getStatusColor"
:get-status-icon="getStatusIcon"
:get-status-text="getStatusText"
:get-status-class="getStatusClass"
:format-date-time="formatDateTime"
:format-date="formatDate"
@update:search="historySearch = $event"
@update:date-filter="historyDateFilter = $event"
@update:status-filter="historyStatusFilter = $event"
@delete-item="handleDeleteHistoryItem"
@clear="handleClearHistory"
/>
<!-- QR History Dialog -->
<QRHistoryDialog
v-model="qrHistoryDialog"
:search="historySearch"
:date-filter="historyDateFilter"
:filtered-history="filteredQRHistory"
:history="scannedQRHistory"
:primary-color="primaryColor"
@update:search="historySearch = $event"
@update:date-filter="historyDateFilter = $event"
@use-data="useQRData"
@delete-item="handleDeleteQRHistoryItem"
@clear="handleClearQRHistory"
/>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import type { TabValue, InfoAction, CheckInHistoryItem } from '~/types/checkin';
import { PRIMARY_COLOR, SECONDARY_COLOR, QR_STATUS_OPTIONS } from '~/constants/checkin';
import { useSnackbar } from '~/composables/useSnackbar';
import { useCheckInHistory } from '~/composables/useCheckInHistory';
import { useQRScanner } from '~/composables/useQRScanner';
import { useCheckIn } from '~/composables/useCheckIn';
import { useQRGenerator } from '~/composables/useQRGenerator';
import CheckInDialog from '~/components/checkin/CheckInDialog.vue';
import HistoryDialog from '~/components/checkin/HistoryDialog.vue';
import QRHistoryDialog from '~/components/checkin/QRHistoryDialog.vue';
import ManualInputTab from '~/components/checkin/ManualInputTab.vue';
import QRScanTab from '~/components/checkin/QRScanTab.vue';
definePageMeta({
middleware:['auth'],
layout: false,
})
// TypeScript declaration for QRCode
declare global {
interface Window {
QRCode: typeof import('qrcode').default;
}
}
// --- DESAIN & TEMA ---
const primaryColor = ref(PRIMARY_COLOR);
const secondaryColor = ref(SECONDARY_COLOR);
// --- LOGIKA ---
const tab = ref<TabValue>('scan');
const infoDialog = ref(false);
const infoMessage = ref('');
const infoAction = ref<InfoAction>('kembali');
const scannedData = ref<string | null>(null);
const manualInput = ref('');
const manualInputTabRef = ref<InstanceType<typeof ManualInputTab> | null>(null);
// lastCheckInResult sudah dipindahkan ke useCheckIn composable
// Timer untuk auto-close dialog
let dialogAutoCloseTimer: ReturnType<typeof setTimeout> | null = null;
// Fungsi untuk auto-close dialog setelah 15 detik
const autoCloseDialog = () => {
// Clear timer sebelumnya jika ada
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
}
// Set timer untuk menutup dialog setelah 15 detik
dialogAutoCloseTimer = setTimeout(() => {
if (infoDialog.value) {
infoDialog.value = false;
dialogAutoCloseTimer = null;
}
}, 15000); // 15 detik
};
// Snackbar composable (harus didefinisikan sebelum useQRScanner)
const { snackbar, snackbarShow, showSnackbar } = useSnackbar();
// History composable
const {
checkInHistory,
scannedQRHistory,
historySearch,
historyDateFilter,
historyStatusFilter,
filteredHistory,
filteredQRHistory,
loadHistory,
saveToHistory,
deleteHistoryItem,
clearHistory,
loadScannedQRHistory,
saveScannedQRData,
clearQRHistory,
deleteQRHistoryItem,
getStatusColor,
getStatusIcon,
getStatusText,
getStatusClass,
formatDateTime,
formatDate,
historyStatusOptions,
} = useCheckInHistory();
// Computed untuk stats footer
const todayCount = computed(() => {
const today = new Date().toDateString()
return checkInHistory.value.filter((item: CheckInHistoryItem) => {
const itemDate = new Date(item.checkInDate).toDateString()
return itemDate === today
}).length
})
const completedCount = computed(() => {
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
item.status === 'success' || item.status === 'ALLOWED'
).length
})
const pendingCount = computed(() => {
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
item.status === 'pending' || item.status === 'NOT_ALLOWED'
).length
})
// Check-in composable (didefinisikan dulu, saveSuccessfulScan akan di-set nanti)
let saveSuccessfulScanRef: ((qrData: string) => void) | null = null
const {
lastCheckInResult,
processQRCode,
checkInManual: checkInManualComposable,
} = useCheckIn({
showSnackbar,
saveToHistory,
saveSuccessfulScan: (qrData: string) => {
// Delegate ke saveSuccessfulScan dari useQRScanner
if (saveSuccessfulScanRef) {
saveSuccessfulScanRef(qrData)
}
},
onCheckInSuccess: (result) => {
infoMessage.value = result.message
infoAction.value = result.action
infoDialog.value = true
},
autoCloseDialog,
})
// QR Scanner composable (setelah useCheckIn agar bisa akses processQRCode)
const {
isScanning,
hasCamera,
cameraChecking,
cameraReady,
checkCameraAvailability,
testCamera,
startScanning,
stopScanning,
saveSuccessfulScan,
} = useQRScanner(
(decodedText: string) => {
// Callback ketika QR berhasil di-scan
scannedData.value = decodedText
processQRCode(decodedText)
// Simpan ke history scanned QR
saveScannedQRData(decodedText)
},
showSnackbar
)
// Set saveSuccessfulScanRef setelah useQRScanner didefinisikan
saveSuccessfulScanRef = saveSuccessfulScan
// History Dialog
const historyDialog = ref(false);
const qrHistoryDialog = ref(false);
// Generate random patient ID function (untuk digunakan di composable dan quick test buttons)
const generateRandomPatientId = () => {
// Generate random 6-digit number
const randomNum = Math.floor(100000 + Math.random() * 900000);
return `P-${randomNum}`;
};
// QR Generator composable
const {
generatePatientId,
generateStatus,
generatedQRData,
generateRandomId,
generateQuickQR,
generateQRCode,
downloadQR,
copyQRToClipboard,
shareQR,
} = useQRGenerator({
showSnackbar,
generateRandomPatientId,
});
const statusOptions = [...QR_STATUS_OPTIONS] as Array<{ title: string; value: string }>;
// Watch tab changes untuk stop scanner saat pindah tab
watch(tab, (newTab: TabValue) => {
if (newTab !== 'scan' && isScanning.value) {
stopScanning();
}
// Check camera when switching to scan tab
if (newTab === 'scan' && !hasCamera.value && !cameraChecking.value) {
checkCameraAvailability();
}
});
// Check camera on mount
onMounted(() => {
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
if (navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') {
checkCameraAvailability();
} else {
console.warn('MediaDevices API not available. Make sure you are using HTTPS or localhost.');
hasCamera.value = false;
cameraChecking.value = false;
showSnackbar('Warning', 'MediaDevices API tidak tersedia. Pastikan menggunakan HTTPS atau localhost.', 'warning', 'mdi-alert');
}
} else {
hasCamera.value = false;
cameraChecking.value = false;
}
});
// Cleanup saat component unmount
onUnmounted(() => {
if (isScanning.value) {
stopScanning();
}
// Clear dialog auto-close timer
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
dialogAutoCloseTimer = null;
}
});
// onDetect sudah dipindahkan ke useCheckIn composable sebagai processQRCode
const handleInfoAction = async () => {
// Clear timer jika dialog ditutup secara manual
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
dialogAutoCloseTimer = null;
}
infoDialog.value = false;
// Scanner tetap berjalan setelah dialog ditutup
// Ini memungkinkan user untuk segera memproses QR code berikutnya
// tanpa perlu memulai scanner lagi
};
// performCheckIn sudah dipindahkan ke useCheckIn composable
const checkInManual = async () => {
try {
if (!manualInput.value || !manualInput.value.trim()) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert');
return;
}
const patientId = manualInput.value.trim();
// Validate form if available
if (manualInputTabRef.value?.form) {
try {
const isValid = manualInputTabRef.value.form.validate();
if (!isValid) {
return;
}
} catch (e) {
// If validate fails, continue anyway
console.warn('Form validation error:', e);
}
}
await checkInManualComposable(
patientId,
() => {
// onSuccess callback
try {
manualInput.value = '';
if (manualInputTabRef.value?.form) {
try {
manualInputTabRef.value.form.reset();
} catch (e) {
// Ignore reset errors
console.warn('Form reset error:', e);
}
}
} catch (e) {
console.warn('Error in success callback:', e);
}
},
() => {
// onError callback - tidak perlu melakukan apa-apa karena error sudah ditangani di composable
}
);
} catch (error) {
console.error('Error in checkInManual:', error);
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert');
}
};
// generateQRCode, generateQuickQR, downloadQR, copyQRToClipboard, shareQR sudah dipindahkan ke useQRGenerator composable
// History Functions - semua sudah dipindahkan ke useCheckInHistory composable
const openHistoryDialog = () => {
loadHistory();
historyDialog.value = true;
};
const openQRHistoryDialog = () => {
loadScannedQRHistory();
qrHistoryDialog.value = true;
};
const handleDeleteHistoryItem = (index: number) => {
deleteHistoryItem(index);
showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check');
};
const handleClearHistory = () => {
clearHistory();
showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check');
};
const handleDeleteQRHistoryItem = (index: number) => {
deleteQRHistoryItem(index);
showSnackbar('Berhasil', 'Riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const handleClearQRHistory = () => {
clearQRHistory();
showSnackbar('Berhasil', 'Semua riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const useQRData = (qrData: string) => {
qrHistoryDialog.value = false;
scannedData.value = qrData;
processQRCode(qrData);
};
// filteredHistory, filteredQRHistory, dan helper functions sudah dipindahkan ke useCheckInHistory composable
// loadSuccessfulScans sudah dipanggil di dalam useQRScanner composable saat initialization
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/variables';
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>