Files
web-antrean/pages/CheckInPasien/checkIn.vue
T
2025-12-18 15:11:41 +07:00

1481 lines
46 KiB
Vue

<template>
<v-app class="bg-light">
<v-main>
<v-container fluid class="fill-height pa-4">
<v-row align="center" justify="center">
<v-col cols="12" sm="10" md="8" lg="6">
<v-card class="elevation-20 rounded-xl overflow-hidden card-main" :style="cardStyle">
<!-- Header dengan gradient -->
<div class="header-gradient pa-6">
<div class="text-center">
<div class="hospital-icon-wrapper mb-3">
<v-icon size="56" color="white" class="pulse-animation">mdi-hospital-building</v-icon>
</div>
<h1 class="text-h4 font-weight-bold text-white mb-2">Check-in Pasien</h1>
<p class="text-subtitle-1 text-white opacity-90">Sistem Antrean Rumah Sakit</p>
</div>
<!-- Tabs dengan desain modern -->
<v-tabs
v-model="tab"
align-tabs="center"
class="mt-6 custom-tabs"
bg-color="transparent"
slider-color="white"
height="48"
>
<v-tab value="scan" class="text-white tab-item">
<v-icon start>mdi-qrcode-scan</v-icon>
Scan QR
</v-tab>
<v-tab value="manual" class="text-white tab-item">
<v-icon start>mdi-keyboard</v-icon>
Manual
</v-tab>
<v-tab value="generate" class="text-white tab-item">
<v-icon start>mdi-qrcode</v-icon>
Generate QR
</v-tab>
</v-tabs>
</div>
<v-card-text class="pa-8">
<v-window v-model="tab">
<!-- Tab Scan QR -->
<v-window-item value="scan">
<div class="scan-section">
<div class="text-center mb-6">
<v-icon :color="primaryColor" size="32" class="mb-2">mdi-information-outline</v-icon>
<p class="text-h6 font-weight-medium text-grey-darken-2">
Arahkan Kamera ke QR Code
</p>
<p class="text-body-2 text-grey">
Pastikan QR code terlihat jelas dan tidak terpotong
</p>
</div>
<!-- QR Scanner Area dengan animasi -->
<div class="qr-scanner-container mb-6">
<div class="qr-placeholder">
<div class="scanner-overlay">
<div class="corner corner-tl"></div>
<div class="corner corner-tr"></div>
<div class="corner corner-bl"></div>
<div class="corner corner-br"></div>
<div class="scan-line"></div>
</div>
<v-icon size="80" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
</div>
</div>
<!-- Button dengan gradient -->
<div class="d-flex justify-center">
<v-btn
class="gradient-button fixed-width-button"
size="large"
elevation="8"
@click="checkMockStatus"
>
<v-icon start>mdi-camera</v-icon>
Simulasi Scan QR
</v-btn>
</div>
<!-- Info tambahan -->
<div class="info-card mt-6">
<v-alert
type="info"
variant="tonal"
:color="primaryColor"
class="text-body-2"
>
<template v-slot:prepend>
<v-icon>mdi-lightbulb-outline</v-icon>
</template>
Tips: Pastikan pencahayaan cukup untuk hasil scan optimal
</v-alert>
</div>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-6">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="openHistoryDialog"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat
</v-btn>
</v-col>
</v-row>
</div>
</div>
</v-window-item>
<!-- Tab Manual -->
<v-window-item value="manual">
<div class="manual-section">
<div class="text-center mb-6">
<v-icon :color="secondaryColor" size="32" class="mb-2">mdi-form-textbox</v-icon>
<p class="text-h6 font-weight-medium text-grey-darken-2">
Input Manual
</p>
<p class="text-body-2 text-grey">
Masukkan nomor antrean atau ID pasien
</p>
</div>
<v-form @submit.prevent="checkInManual" ref="manualForm">
<v-text-field
v-model="manualInput"
label="Nomor Antrean / ID Pasien"
placeholder="Contoh: P12345"
prepend-inner-icon="mdi-account-card-details"
variant="outlined"
:color="primaryColor"
class="mb-2 custom-input"
required
:rules="[v => !!v || 'Field ini wajib diisi']"
density="comfortable"
>
<template v-slot:append-inner>
<v-icon :color="manualInput ? 'success' : 'grey'">
{{ manualInput ? 'mdi-check-circle' : 'mdi-circle-outline' }}
</v-icon>
</template>
</v-text-field>
<div class="d-flex justify-center">
<v-btn
:color="secondaryColor"
class="text-white font-weight-bold text-none gradient-button-secondary fixed-width-button"
size="large"
type="submit"
elevation="8"
>
<v-icon start>mdi-login</v-icon>
Check-in Sekarang
</v-btn>
</div>
</v-form>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-6">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="openHistoryDialog"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat
</v-btn>
</v-col>
</v-row>
</div>
</div>
</v-window-item>
<!-- Tab Generate QR -->
<v-window-item value="generate">
<div class="generate-section">
<div class="text-center mb-6">
<v-icon :color="primaryColor" size="32" class="mb-2">mdi-qrcode</v-icon>
<p class="text-h6 font-weight-medium text-grey-darken-2">
Generate QR Code untuk Testing
</p>
<p class="text-body-2 text-grey">
Buat QR code yang bisa Anda scan di tab "Scan QR"
</p>
</div>
<!-- Form Generate -->
<v-form @submit.prevent="generateQRCode">
<v-text-field
v-model="generatePatientId"
label="ID Pasien"
placeholder="Contoh: P12345"
prepend-inner-icon="mdi-identifier"
variant="outlined"
:color="primaryColor"
class="mb-4 custom-input"
density="comfortable"
></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="mb-4 custom-input"
density="comfortable"
></v-select>
<div class="d-flex justify-center">
<v-btn
:color="primaryColor"
class="gradient-button fixed-width-button"
size="large"
type="submit"
elevation="8"
>
<v-icon start>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="6">
<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="6">
<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:</strong>
<ol class="ml-4 mt-2">
<li>Klik "Download" atau "Share" untuk menyimpan/mengirim QR ke HP Anda</li>
<li>Buka QR code di HP Anda</li>
<li>Pindah ke tab "Scan QR" dan scan QR code tersebut</li>
</ol>
</v-alert>
</div>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<!-- Stats Footer -->
<div class="stats-footer mt-4">
<v-row>
<v-col cols="4">
<div class="stat-card">
<v-icon :color="primaryColor" size="24">mdi-clock-outline</v-icon>
<div class="text-caption text-grey mt-1">Hari Ini</div>
<div class="text-h6 font-weight-bold">24</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card">
<v-icon color="success" size="24">mdi-check-circle</v-icon>
<div class="text-caption text-grey mt-1">Selesai</div>
<div class="text-h6 font-weight-bold">18</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card">
<v-icon :color="secondaryColor" size="24">mdi-account-group</v-icon>
<div class="text-caption text-grey mt-1">Menunggu</div>
<div class="text-h6 font-weight-bold">6</div>
</div>
</v-col>
</v-row>
</div>
</v-col>
</v-row>
</v-container>
</v-main>
<!-- Enhanced Dialog with Blur -->
<v-dialog
v-model="infoDialog"
max-width="500"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-8" :class="infoAction === 'checkin' ? 'success-header' : 'warning-header'">
<div class="decorative-circles">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<div class="icon-wrapper mb-4">
<div class="icon-bg" :class="infoAction === 'checkin' ? 'icon-bg-success' : 'icon-bg-warning'">
<v-icon size="64" color="white" class="dialog-icon">
{{ infoAction === 'checkin' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
</div>
</div>
<h2 class="text-h4 font-weight-bold text-white mb-2">
{{ infoAction === 'checkin' ? 'Siap Check-in!' : 'Belum Diizinkan' }}
</h2>
<div class="status-badge mt-3">
<v-chip
:color="infoAction === 'checkin' ? 'white' : 'white'"
:text-color="infoAction === 'checkin' ? 'success' : 'orange'"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">
{{ infoAction === 'checkin' ? 'mdi-check' : 'mdi-clock-alert' }}
</v-icon>
{{ infoAction === 'checkin' ? 'Diizinkan' : 'Menunggu' }}
</v-chip>
</div>
</div>
<v-card-text class="pa-8">
<div class="message-container">
<div class="message-icon mb-4">
<v-icon :color="infoAction === 'checkin' ? 'success' : 'orange'" size="32">
mdi-information-outline
</v-icon>
</div>
<p class="text-h6 font-weight-bold mb-3 text-center">{{ infoMessage }}</p>
<v-divider class="my-4"></v-divider>
<div class="instruction-box pa-4 rounded-lg" :class="infoAction === 'checkin' ? 'instruction-success' : 'instruction-warning'">
<div class="d-flex align-start">
<v-icon :color="infoAction === 'checkin' ? 'success' : 'orange'" class="mr-3 mt-1">
{{ infoAction === 'checkin' ? 'mdi-hand-pointing-down' : 'mdi-timer-sand' }}
</v-icon>
<div>
<p class="text-body-1 font-weight-medium mb-1">
{{ infoAction === 'checkin' ? 'Langkah Selanjutnya:' : 'Informasi:' }}
</p>
<p class="text-body-2 text-grey-darken-1">
{{ infoAction === 'checkin' ? 'Klik tombol Check-in di bawah untuk melanjutkan proses registrasi pasien' : 'Mohon menunggu hingga antrean Anda dipanggil oleh petugas' }}
</p>
</div>
</div>
</div>
<!-- Patient Info Card -->
<v-card variant="outlined" class="mt-4 patient-info-card" color="grey-lighten-4">
<v-card-text class="py-3">
<div class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-account-circle</v-icon>
<div>
<p class="text-caption text-grey mb-0">ID Pasien</p>
<p class="text-body-1 font-weight-bold mb-0">{{ scannedData?.split('|')[0] || 'N/A' }}</p>
</div>
</div>
<div class="text-right">
<p class="text-caption text-grey mb-0">Waktu Scan</p>
<p class="text-body-2 font-weight-medium mb-0">{{ new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) }}</p>
</div>
</div>
</v-card-text>
</v-card>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-row dense>
<v-col v-if="infoAction === 'kembali'" cols="12">
<v-btn
color="grey-darken-1"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleInfoAction"
elevation="0"
prepend-icon="mdi-arrow-left"
>
Kembali
</v-btn>
</v-col>
<template v-else>
<v-col cols="6">
<v-btn
color="grey-lighten-1"
class="text-none dialog-button"
size="x-large"
block
variant="outlined"
@click="infoDialog = false"
prepend-icon="mdi-close"
>
Batal
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
color="success"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleInfoAction"
elevation="4"
prepend-icon="mdi-login"
>
Check-in
</v-btn>
</v-col>
</template>
</v-row>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Enhanced Snackbar -->
<v-snackbar
v-model="snackbar.show"
: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 -->
<v-dialog
v-model="historyDialog"
max-width="800"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-history</v-icon>
Riwayat Check-in
</h2>
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="historySearch"
label="Cari ID Pasien atau Nomor Antrean"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="historyStatusFilter"
label="Filter Status"
:items="historyStatusOptions"
variant="outlined"
density="comfortable"
hide-details
clearable
></v-select>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="clearHistory"
:disabled="checkInHistory.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- History List -->
<div v-if="filteredHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="mr-2"
>
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
</v-chip>
<v-chip
color="grey-lighten-1"
size="x-small"
variant="text"
>
{{ item.method }}
</v-chip>
</div>
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
ID Pasien: {{ item.patientId }}
</p>
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
Nomor Antrean: {{ item.queueNumber }}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-clock-outline</v-icon>
{{ formatDateTime(item.checkInTime) }}
</v-chip>
<v-chip
v-if="item.checkInDate"
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ formatDate(item.checkInDate) }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="error"
@click="deleteHistoryItem(index)"
>
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-bold text-none"
size="large"
variant="flat"
@click="historyDialog = false"
prepend-icon="mdi-close"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
definePageMeta({
middleware:['auth'],
layout: false,
})
// TypeScript declaration for QRCode
declare global {
interface Window {
QRCode: any;
}
}
// --- DESAIN & TEMA ---
const primaryColor = ref('#1565C0');
const secondaryColor = ref('#FB8C00');
const cardStyle = computed(() => ({
background: 'white',
}));
// --- LOGIKA ---
const tab = ref('scan');
const infoDialog = ref(false);
const infoMessage = ref('');
const infoAction = ref<'checkin' | 'kembali'>('kembali');
const scannedData = ref<string | null>(null);
const manualInput = ref('');
const manualForm = ref(null);
// History Dialog
const historyDialog = ref(false);
const historySearch = ref('');
const historyStatusFilter = ref('');
const checkInHistory = ref<Array<{
patientId: string;
queueNumber?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
}>>([]);
const historyStatusOptions = [
{ title: 'Berhasil', value: 'success' },
{ title: 'Gagal', value: 'failed' },
{ title: 'Pending', value: 'pending' }
];
// Generate QR variables
const generatePatientId = ref('P12345');
const generateStatus = ref('ALLOWED');
const generatedQRData = ref('');
const statusOptions = [
{ title: 'Diizinkan Check-in', value: 'ALLOWED' },
{ title: 'Belum Diizinkan', value: 'NOT_ALLOWED' }
];
const snackbar = ref({
show: false,
title: '',
message: '',
color: '',
icon: '',
timeout: 4000,
});
const checkMockStatus = () => {
const isCurrentlyAllowed = infoAction.value === 'checkin';
const mockData = isCurrentlyAllowed ? 'P67890|NOT_ALLOWED' : 'P12345|ALLOWED';
scannedData.value = mockData;
onDetect(mockData);
};
const onDetect = (decodedText: string) => {
const [patientId, status] = decodedText.split('|');
if (status === 'ALLOWED') {
infoMessage.value = `Antrean Pasien ${patientId} sudah diperbolehkan check-in`;
infoAction.value = 'checkin';
infoDialog.value = true;
} else if (status === 'NOT_ALLOWED') {
infoMessage.value = `Antrean Pasien ${patientId} belum diperbolehkan check-in`;
infoAction.value = 'kembali';
infoDialog.value = true;
} else {
showSnackbar('Gagal', 'QR Code tidak valid.', 'error', 'mdi-close-circle');
}
};
const handleInfoAction = async () => {
infoDialog.value = false;
if (infoAction.value === 'checkin') {
const checkinSuccess = await performCheckIn(scannedData.value!);
if (checkinSuccess) {
showSnackbar('Berhasil!', 'Check-in antrean berhasil dilakukan.', 'success', 'mdi-check-circle');
} else {
showSnackbar('Gagal!', 'Check-in antrean gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle');
}
}
};
const performCheckIn = async (data: string): Promise<boolean> => {
await new Promise(resolve => setTimeout(resolve, 1000));
const success = Math.random() < 0.8;
// Simpan ke history (baik berhasil maupun gagal)
const [patientId, status] = data.split('|');
saveToHistory({
patientId: patientId || 'Unknown',
status: success ? (status || 'ALLOWED') : 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: scannedData.value ? 'QR Scan' : 'Manual'
});
return success;
};
const showSnackbar = (title: string, message: string, color: string, icon: string) => {
snackbar.value.title = title;
snackbar.value.message = message;
snackbar.value.color = color;
snackbar.value.icon = icon;
snackbar.value.show = true;
};
const checkInManual = async () => {
if (!manualInput.value) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert');
return;
}
// Simulasi check-in manual
const success = await performCheckIn(`${manualInput.value}|ALLOWED`);
if (success) {
showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle');
manualInput.value = '';
if (manualForm.value) {
(manualForm.value as any).reset();
}
} else {
showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle');
}
};
// Generate QR Code function
const generateQRCode = async () => {
if (!generatePatientId.value) {
showSnackbar('Error', 'Mohon isi ID Pasien', 'error', 'mdi-alert');
return;
}
generatedQRData.value = `${generatePatientId.value}|${generateStatus.value}`;
await nextTick();
// Clear previous QR code
const qrContainer = document.getElementById('qrcode');
if (qrContainer) {
qrContainer.innerHTML = '';
// Load QRCode.js from CDN if not already loaded
if (typeof window.QRCode === 'undefined') {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js';
script.onload = () => createQR();
document.head.appendChild(script);
} else {
createQR();
}
}
showSnackbar('Berhasil!', 'QR Code berhasil di-generate', 'success', 'mdi-check-circle');
};
const createQR = () => {
const qrContainer = document.getElementById('qrcode');
if (qrContainer && window.QRCode) {
new window.QRCode(qrContainer, {
text: generatedQRData.value,
width: 256,
height: 256,
colorDark: '#1565C0',
colorLight: '#ffffff',
correctLevel: window.QRCode.CorrectLevel.H
});
}
};
const downloadQR = () => {
const canvas = document.querySelector('#qrcode canvas') as HTMLCanvasElement;
if (canvas) {
const url = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = `QR-${generatePatientId.value}.png`;
link.href = url;
link.click();
showSnackbar('Berhasil!', 'QR Code berhasil didownload', 'success', 'mdi-download');
}
};
const shareQR = async () => {
const canvas = document.querySelector('#qrcode canvas') as HTMLCanvasElement;
if (canvas) {
canvas.toBlob(async (blob) => {
if (blob) {
const file = new File([blob], `QR-${generatePatientId.value}.png`, { type: 'image/png' });
if (navigator.share && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: 'QR Code Check-in',
text: `QR Code untuk pasien ${generatePatientId.value}`
});
showSnackbar('Berhasil!', 'QR Code berhasil dibagikan', 'success', 'mdi-share');
} catch (err: any) {
if (err.name !== 'AbortError') {
showSnackbar('Info', 'Gagal membagikan. Silakan download dan kirim manual.', 'info', 'mdi-information');
}
}
} else {
// Fallback: copy to clipboard atau download
showSnackbar('Info', 'Browser tidak support share. Silakan gunakan Download.', 'info', 'mdi-information');
}
}
});
}
};
// History Functions
const HISTORY_STORAGE_KEY = 'checkin_history';
const loadHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
if (stored) {
try {
checkInHistory.value = JSON.parse(stored);
} catch (e) {
console.error('Error loading history:', e);
checkInHistory.value = [];
}
}
}
};
const saveToHistory = (item: {
patientId: string;
queueNumber?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
}) => {
const historyItem = {
...item,
queueNumber: item.queueNumber || `ANT-${Date.now()}`,
};
checkInHistory.value.unshift(historyItem);
// Simpan maksimal 100 item
if (checkInHistory.value.length > 100) {
checkInHistory.value = checkInHistory.value.slice(0, 100);
}
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value));
}
};
const deleteHistoryItem = (index: number) => {
checkInHistory.value.splice(index, 1);
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value));
}
showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check');
};
const clearHistory = () => {
checkInHistory.value = [];
if (typeof window !== 'undefined') {
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check');
};
const openHistoryDialog = () => {
loadHistory();
historyDialog.value = true;
};
const filteredHistory = computed(() => {
let filtered = [...checkInHistory.value];
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase();
filtered = filtered.filter(item =>
item.patientId.toLowerCase().includes(search) ||
(item.queueNumber && item.queueNumber.toLowerCase().includes(search))
);
}
// Filter by status
if (historyStatusFilter.value) {
filtered = filtered.filter(item => {
if (historyStatusFilter.value === 'success') {
return item.status === 'ALLOWED' || item.status === 'success';
} else if (historyStatusFilter.value === 'failed') {
return item.status === 'NOT_ALLOWED' || item.status === 'failed';
}
return true;
});
}
return filtered;
});
const getStatusColor = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'success';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'error';
return 'warning';
};
const getStatusIcon = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle';
return 'mdi-clock-alert';
};
const getStatusText = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'Berhasil';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal';
return 'Pending';
};
const getStatusClass = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'history-success';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed';
return 'history-pending';
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
};
// Load history on mount
if (typeof window !== 'undefined') {
loadHistory();
}
</script>
<style scoped>
.bg-light {
background: #f5f7fa;
min-height: 100vh;
}
.card-main {
transition: all 0.3s ease;
}
.card-main:hover {
transform: translateY(-4px);
}
.header-gradient {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
position: relative;
overflow: hidden;
}
.header-gradient::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
animation: pulse 4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 0.8; }
}
.hospital-icon-wrapper {
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.pulse-animation {
animation: pulse-icon 2s ease-in-out infinite;
}
@keyframes pulse-icon {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.custom-tabs :deep(.v-tab) {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
font-size: 0.9rem;
}
.custom-tabs :deep(.v-tab--selected) {
background: rgba(255, 255, 255, 0.2);
border-radius: 8px;
}
.scan-section, .manual-section, .generate-section {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.qr-scanner-container {
display: flex;
justify-content: center;
align-items: center;
}
.qr-placeholder {
width: 100%;
max-width: 320px;
height: 320px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 16px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: inset 0 0 20px rgba(0,0,0,0.1);
}
.scanner-overlay {
position: absolute;
width: 80%;
height: 80%;
}
.corner {
position: absolute;
width: 40px;
height: 40px;
border: 3px solid #1565C0;
}
.corner-tl {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
border-radius: 8px 0 0 0;
}
.corner-tr {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
border-radius: 0 8px 0 0;
}
.corner-bl {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
border-radius: 0 0 0 8px;
}
.corner-br {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
border-radius: 0 0 8px 0;
}
.scan-line {
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #1565C0, transparent);
top: 0;
animation: scan 2s linear infinite;
box-shadow: 0 0 10px #1565C0;
}
@keyframes scan {
0% { top: 0; }
100% { top: 100%; }
}
.qr-icon {
position: relative;
z-index: 1;
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.gradient-button {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%) !important;
color: white !important;
font-weight: 700;
text-transform: none;
letter-spacing: 0.5px;
transition: all 0.3s ease;
}
.gradient-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(21, 101, 192, 0.4) !important;
}
.gradient-button-secondary {
background: linear-gradient(135deg, #FB8C00 0%, #F57C00 100%) !important;
color: white !important;
font-weight: 700;
text-transform: none;
letter-spacing: 0.5px;
transition: all 0.3s ease;
}
.gradient-button-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(251, 140, 0, 0.4) !important;
}
.fixed-width-button {
width: 280px !important;
min-width: 280px !important;
max-width: 280px !important;
}
.custom-input :deep(.v-field) {
border-radius: 12px;
font-size: 16px;
}
.custom-input :deep(.v-field--focused) {
box-shadow: 0 0 0 3px rgba(21, 101, 192, 0.1);
}
.quick-actions .v-btn {
transition: all 0.3s ease;
}
.quick-actions .v-btn:hover {
transform: translateY(-2px);
}
.qr-code-container {
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.qr-code-container :deep(canvas) {
border-radius: 8px;
}
.qr-display {
animation: slideUp 0.5s ease-out;
}
.blur-dialog :deep(.v-overlay__scrim) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.dialog-card {
overflow: hidden;
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.8) translateY(20px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dialog-header {
position: relative;
overflow: hidden;
}
.dialog-header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%);
animation: rotate 10s linear infinite;
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.icon-wrapper {
display: inline-block;
animation: bounceIn 0.6s ease-out 0.2s both;
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.dialog-icon {
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.3));
}
.success-header {
background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%);
}
.warning-header {
background: linear-gradient(135deg, #FFA726 0%, #FB8C00 100%);
}
.dialog-button {
text-transform: none;
letter-spacing: 0.5px;
font-weight: 600;
transition: all 0.3s ease;
}
.dialog-button:hover {
transform: translateY(-2px);
}
.stats-footer {
animation: slideUp 0.5s ease-out 0.3s both;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.stat-card {
background: white;
border-radius: 12px;
padding: 16px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
}
.custom-snackbar {
margin-bottom: 16px;
margin-right: 16px;
}
.custom-snackbar :deep(.v-snackbar__wrapper) {
min-width: 300px;
}
/* History Dialog Styles */
.history-list {
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
.history-item {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.history-item:hover {
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.history-success {
border-left-color: #4caf50;
background: rgba(76, 175, 80, 0.05);
}
.history-failed {
border-left-color: #f44336;
background: rgba(244, 67, 54, 0.05);
}
.history-pending {
border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.05);
}
/* Mobile Optimization */
@media (max-width: 600px) {
.v-container.fill-height {
padding: 8px !important;
}
.card-main {
margin: 0;
}
.header-gradient {
padding: 24px 16px !important;
}
.qr-placeholder {
max-width: 280px;
height: 280px;
}
.stats-footer {
display: none;
}
.custom-tabs :deep(.v-tab) {
font-size: 0.75rem;
padding: 0 8px;
}
}
</style>