Files
web-antrean/pages/CheckInPasien/checkIn.vue
T
2026-01-08 12:44:40 +07:00

3915 lines
129 KiB
Vue

<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="28" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="title-modern">Check-in Pasien</h1>
</div>
</div>
<!-- Tabs Minimalis -->
<v-tabs
v-model="tab"
align-tabs="center"
class="tabs-modern mt-1"
bg-color="transparent"
slider-color="#FB8C00"
height="32"
>
<v-tab value="checkin" class="tab-modern">
<v-icon size="18" class="mr-2">mdi-login</v-icon>
<span>Check-in</span>
</v-tab>
<v-tab value="generate" class="tab-modern">
<v-icon size="18" 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" :key="`window-${tab}`">
<!-- Tab Check-in (Combined Scan QR + Manual) -->
<v-window-item value="checkin" :key="'checkin-tab'">
<div class="checkin-wrapper">
<v-row class="checkin-layout" no-gutters>
<!-- Left: Scan QR -->
<v-col cols="12" md="6" class="checkin-left-col">
<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-scan</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">
{{ isScanning ? 'Arahkan Kamera ke QR Code' : 'Siap untuk Scan QR Code' }}
</h3>
<p class="status-subtitle">
{{ isScanning ? 'Pastikan QR code terlihat jelas dan tidak terpotong' : 'Klik tombol di bawah untuk memulai scan' }}
</p>
</div>
</div>
<!-- Camera Status Check -->
<div v-if="cameraChecking" class="text-center mb-4">
<v-progress-circular
indeterminate
:color="primaryColor"
size="24"
class="mr-2"
></v-progress-circular>
<span class="text-body-2 text-grey">Memeriksa ketersediaan kamera...</span>
</div>
<!-- Camera Not Available Warning -->
<v-alert
v-else-if="!hasCamera && !isScanning"
type="warning"
variant="tonal"
class="mb-4"
>
<template v-slot:prepend>
<v-icon>mdi-camera-off</v-icon>
</template>
<div>
<p class="font-weight-bold mb-1">Kamera tidak terdeteksi</p>
<p class="text-body-2 mb-0">
Perangkat Anda tidak memiliki kamera atau kamera tidak dapat diakses.
Silakan gunakan input <strong>Manual</strong> di sebelah kanan.
</p>
</div>
</v-alert>
<!-- QR Scanner Area dengan webcam -->
<div class="qr-scanner-container mb-4">
<div v-if="!isScanning" 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="64" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
</div>
<div v-else class="qr-reader-container">
<div class="scanner-status mb-2">
<v-chip color="success" size="small" class="mr-2">
<v-icon start size="16">mdi-camera</v-icon>
Kamera Aktif
</v-chip>
<span class="text-caption text-grey">Preview kamera sedang berjalan</span>
</div>
<div id="qr-reader" class="qr-reader-wrapper">
<div class="scanner-loading-overlay" v-if="!cameraReady">
<v-progress-circular
indeterminate
color="white"
size="48"
></v-progress-circular>
<p class="text-white mt-4">Memuat kamera...</p>
</div>
</div>
<div class="scanner-instruction">
<v-icon :color="primaryColor" size="20" class="mr-2">mdi-information</v-icon>
<span class="text-body-2">Arahkan kamera ke QR code. Pastikan QR code berada dalam kotak pemindaian.</span>
</div>
</div>
</div>
<!-- Action Button Minimalis -->
<div class="action-buttons">
<v-btn
v-if="!isScanning"
class="btn-primary-modern btn-centered"
size="large"
elevation="0"
@click="startScanning"
:disabled="!hasCamera && !cameraChecking"
block
>
<v-icon start size="20">mdi-camera</v-icon>
{{ hasCamera ? 'Mulai Scan QR' : 'Kamera Tidak Tersedia' }}
</v-btn>
<v-btn
v-else
class="btn-stop-modern btn-centered"
size="large"
elevation="0"
@click="stopScanning"
block
>
<v-icon start size="20">mdi-camera-off</v-icon>
Stop Scan
</v-btn>
</div>
<!-- Info tambahan -->
<div class="info-card mt-3">
<v-alert
type="info"
variant="tonal"
:color="primaryColor"
class="text-body-2 info-alert-centered"
density="compact"
>
<div class="d-flex align-center justify-center">
<v-icon size="16" class="mr-2">mdi-lightbulb-outline</v-icon>
<span style="font-size: 12px;">Tips: Pastikan pencahayaan cukup untuk hasil scan optimal</span>
</div>
</v-alert>
</div>
<!-- Test Camera Button (Debug) -->
<div class="test-camera-section mt-2 mb-2">
<v-btn
variant="outlined"
color="primary"
size="small"
class="text-none btn-centered-small btn-test-camera"
@click="testCamera"
block
>
<v-icon start size="18" color="#1565C0">mdi-camera</v-icon>
Test Kamera
</v-btn>
<p class="text-caption text-grey text-center mt-1" style="font-size: 11px;">
Klik untuk menguji apakah browser dapat mengakses kamera
</p>
</div>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-4">
<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 Check-in
</v-btn>
</v-col>
<v-col cols="12">
<v-btn
variant="outlined"
color="primary"
block
size="small"
class="text-none"
@click="openQRHistoryDialog"
>
<v-icon start size="18" color="#1565C0">mdi-qrcode-scan</v-icon>
Riwayat QR Scan
</v-btn>
</v-col>
</v-row>
</div>
</div>
</v-col>
<!-- Right: Manual Input -->
<v-col cols="12" md="6" class="checkin-right-col">
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="secondaryColor" size="24">mdi-keyboard</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Input Manual</h3>
<p class="status-subtitle">Masukkan nomor antrean atau ID pasien</p>
</div>
</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="input-modern mb-4"
required
:rules="[v => !!v || 'Field ini wajib diisi']"
density="comfortable"
hide-details="auto"
>
<template v-slot:append-inner>
<v-icon :color="manualInput ? 'success' : 'grey'" size="20">
{{ manualInput ? 'mdi-check-circle' : 'mdi-circle-outline' }}
</v-icon>
</template>
</v-text-field>
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
block
>
<v-icon start size="20">mdi-login</v-icon>
Check-in Sekarang
</v-btn>
</v-form>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-4">
<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>
Lihat Semua Riwayat
</v-btn>
</v-col>
</v-row>
</div>
<!-- Riwayat Check-in -->
<div class="history-section mt-6">
<div class="d-flex align-center justify-space-between mb-3">
<h4 class="text-subtitle-1 font-weight-bold">
<v-icon size="20" class="mr-2" :color="primaryColor">mdi-history</v-icon>
Riwayat Check-in
</h4>
<v-chip size="x-small" color="grey-lighten-2">
{{ recentHistory.length }} item
</v-chip>
</div>
<div v-if="recentHistory.length > 0" class="recent-history-list">
<v-card
v-for="(item, index) in recentHistory"
:key="index"
variant="outlined"
class="mb-2 history-item-compact"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-3">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<!-- Status & Method Chips -->
<div class="d-flex align-center flex-wrap gap-1 mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="x-small"
density="compact"
>
<v-icon start size="12">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
</v-chip>
<v-chip
color="grey-lighten-1"
size="x-small"
variant="text"
density="compact"
>
{{ item.method }}
</v-chip>
</div>
<!-- Patient ID -->
<div class="mb-2">
<p class="text-body-2 font-weight-medium mb-0">
<v-icon size="14" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
{{ item.patientId }}
</p>
</div>
<!-- Antrean & Pembayaran Chips -->
<div class="d-flex align-center flex-wrap gap-1 mb-2">
<v-chip
v-if="item.klinikQueueNumber"
size="x-small"
:color="secondaryColor"
variant="tonal"
density="compact"
>
<v-icon start size="12">mdi-ticket</v-icon>
{{ item.klinikQueueNumber }}
</v-chip>
<v-chip
v-if="item.pembayaran"
size="x-small"
:color="primaryColor"
variant="tonal"
density="compact"
>
<v-icon start size="12">mdi-credit-card</v-icon>
{{ item.pembayaran }}
</v-chip>
</div>
<!-- Time -->
<div class="d-flex align-center">
<v-chip
size="x-small"
variant="text"
color="grey-darken-1"
density="compact"
>
<v-icon start size="12">mdi-clock-outline</v-icon>
{{ formatTime(item.checkInTime) }}
</v-chip>
</div>
</div>
</div>
</v-card-text>
</v-card>
</div>
<div v-else class="text-center py-6">
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-history</v-icon>
<p class="text-body-2 text-grey">Belum ada riwayat check-in</p>
</div>
</div>
</div>
</v-col>
</v-row>
</div>
</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('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('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-select
v-model="selectedKlinik"
label="Pilih Klinik/Poli"
:items="klinikOptions"
prepend-inner-icon="mdi-hospital-building"
variant="outlined"
:color="primaryColor"
class="input-modern mb-3"
density="comfortable"
:rules="[v => !!v || 'Klinik/Poli harus dipilih']"
hide-details="auto"
required
></v-select>
<v-text-field
v-model="generatePatientId"
label="ID Pasien"
placeholder="Auto-generate: P-00001"
prepend-inner-icon="mdi-identifier"
variant="outlined"
:color="primaryColor"
class="input-modern mb-3"
density="comfortable"
readonly
hint="ID Pasien akan di-generate secara otomatis secara sequential"
persistent-hint
hide-details="auto"
>
<template v-slot:append-inner>
<v-btn
icon
size="small"
variant="text"
@click="generatePatientId = generateSequentialPatientId()"
title="Generate ID Baru"
>
<v-icon size="18">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="!selectedKlinik"
>
<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">{{ statsToday }}</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">{{ statsCompleted }}</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">{{ statsWaiting }}</div>
<div class="stat-label">Menunggu</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="lastCheckInResult?.success && infoAction === 'checkin' ? 'success-header' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'warning-header' : 'error-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="lastCheckInResult?.success && infoAction === 'checkin' ? 'icon-bg-success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'icon-bg-warning' : 'icon-bg-error'">
<v-icon size="64" color="white" class="dialog-icon">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-alert-circle' : 'mdi-close-circle' }}
</v-icon>
</div>
</div>
<h2 class="text-h4 font-weight-bold text-white mb-2">
{{ lastCheckInResult?.success && infoAction === 'checkin'
? 'Check-in Berhasil!'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'Belum Diizinkan'
: 'Check-in Gagal' }}
</h2>
<div class="status-badge mt-3">
<v-chip
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'white' : 'white'"
:text-color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
</v-icon>
{{ lastCheckInResult?.success && infoAction === 'checkin'
? 'Berhasil'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'Menunggu'
: 'Gagal' }}
</v-chip>
</div>
</div>
<v-card-text class="pa-8">
<div class="message-container">
<div class="message-icon mb-4">
<v-icon :color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'" size="32">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
</v-icon>
</div>
<div class="text-h6 font-weight-bold mb-3 text-center" style="white-space: pre-line;">{{ infoMessage }}</div>
<v-divider class="my-4"></v-divider>
<div v-if="lastCheckInResult" class="instruction-box pa-4 rounded-lg" :class="lastCheckInResult.success && infoAction === 'checkin' ? 'instruction-success' : 'instruction-warning'">
<div class="d-flex align-start">
<v-icon :color="lastCheckInResult.success && infoAction === 'checkin' ? 'success' : 'orange'" class="mr-3 mt-1">
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'mdi-check-circle' : 'mdi-timer-sand' }}
</v-icon>
<div>
<p class="text-body-1 font-weight-medium mb-1">
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'Status Check-in:' : 'Status:' }}
</p>
<p class="text-body-2 text-grey-darken-1">
{{ lastCheckInResult.success && infoAction === 'checkin'
? 'Check-in telah berhasil dilakukan. Pasien dapat melanjutkan ke tahap selanjutnya.'
: lastCheckInResult.status === 'NOT_ALLOWED'
? 'Mohon menunggu hingga antrean Anda dipanggil oleh petugas'
: 'Proses check-in gagal. Silakan coba lagi atau hubungi 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 patient-info-icon">mdi-account-circle</v-icon>
<div>
<p class="text-caption patient-info-label mb-0">ID Pasien</p>
<p class="text-body-1 patient-info-value mb-0">{{ scannedData?.split('|')[0] || 'N/A' }}</p>
</div>
</div>
<div class="text-right">
<p class="text-caption patient-info-label mb-0">Waktu Scan</p>
<p class="text-body-2 patient-info-value 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="12">
<v-btn
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : 'primary'"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleInfoAction"
elevation="4"
:prepend-icon="lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : 'mdi-close'"
>
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'Tutup' : 'Tutup' }}
</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>
<!-- Patient Info -->
<div class="mb-3">
<p class="text-body-1 font-weight-bold mb-2">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
{{ item.patientId }}
</p>
<!-- Antrean & Pembayaran Info Grid -->
<div class="d-flex flex-wrap gap-2 mb-2">
<v-chip
v-if="item.klinikQueueNumber"
size="small"
:color="secondaryColor"
variant="tonal"
density="comfortable"
>
<v-icon start size="16">mdi-ticket</v-icon>
{{ item.klinikQueueNumber }}
</v-chip>
<v-chip
v-if="item.pembayaran"
size="small"
:color="primaryColor"
variant="tonal"
density="comfortable"
>
<v-icon start size="16">mdi-credit-card</v-icon>
{{ item.pembayaran }}
</v-chip>
</div>
</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>
<!-- QR History Dialog -->
<v-dialog
v-model="qrHistoryDialog"
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, #FB8C00 0%, #F57C00 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-qrcode-scan</v-icon>
Riwayat QR Scan
</h2>
<p class="text-body-2 text-white opacity-90">Daftar QR code yang telah di-scan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="9">
<v-text-field
v-model="historySearch"
label="Cari Data QR Code"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="clearQRHistory"
:disabled="scannedQRHistory.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- QR History List -->
<div v-if="filteredQRHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredQRHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-qrcode</v-icon>
Data QR: {{ item.data }}
</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>
{{ item.time }}
</v-chip>
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ item.date }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="primary"
@click="useQRData(item.data)"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
color="error"
@click="deleteQRHistoryItem(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-qrcode-scan</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat QR scan</p>
<p class="text-body-2 text-grey">Riwayat QR scan akan muncul di sini setelah Anda melakukan scan</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="qrHistoryDialog = 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, onMounted, onUnmounted, watch } from 'vue';
import { useQueueStore } from '@/stores/queueStore';
import { useMasterStore } from '@/stores/masterStore';
definePageMeta({
// middleware:['auth'],
layout: false,
})
const queueStore = useQueueStore();
const masterStore = useMasterStore();
// TypeScript declaration for QRCode
declare global {
interface Window {
QRCode: any;
}
}
// --- DESAIN & TEMA ---
const primaryColor = ref('#1565C0');
const secondaryColor = ref('#FB8C00');
// --- LOGIKA ---
const tab = ref('checkin');
const infoDialog = ref(false);
const infoMessage = ref('');
const infoAction = ref<'checkin' | 'kembali'>('kembali');
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null;
const scannedData = ref<string | null>(null);
const manualInput = ref('');
const manualForm = ref(null);
const lastCheckInResult = ref<{ success: boolean; patientId: string; status: string } | null>(null);
// QR Scanner state
const isScanning = ref(false);
const hasCamera = ref(false);
const cameraChecking = ref(true);
const cameraReady = ref(false);
let html5QrCode: any = null;
const qrCodeId = 'qr-reader';
let lastScannedQR: string | null = null;
let lastScanTime: number = 0;
const SCAN_DEBOUNCE_MS = 2000; // 2 detik debounce untuk mencegah scan berulang
// Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian)
const SUCCESSFUL_SCANS_KEY = 'successful_qr_scans';
const successfulScans = ref<Set<string>>(new Set());
// Detect mobile device
const isMobile = ref(false);
if (typeof window !== 'undefined') {
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(window.innerWidth <= 768);
}
// History Dialog
const historyDialog = ref(false);
const historySearch = ref('');
const historyStatusFilter = ref('');
const checkInHistory = ref<Array<{
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
}>>([]);
// QR History Dialog
const qrHistoryDialog = ref(false);
const scannedQRHistory = ref<Array<{
data: string;
timestamp: string;
date: string;
time: string;
}>>([]);
const historyStatusOptions = [
{ title: 'Berhasil', value: 'success' },
{ title: 'Gagal', value: 'failed' },
{ title: 'Pending', value: 'pending' }
];
// Generate QR variables
const generatePatientId = ref('');
const generateStatus = ref('ALLOWED');
const generatedQRData = ref('');
const selectedKlinik = ref('');
const statusOptions = [
{ title: 'Diizinkan Check-in', value: 'ALLOWED' },
{ title: 'Belum Diizinkan', value: 'NOT_ALLOWED' }
];
// Get klinik list for dropdown
const klinikOptions = computed(() => {
const klinikList = masterStore.klinikList.map((k: { kode: string; nama: string }) => ({
title: `${k.kode} - ${k.nama}`,
value: k.kode
}));
// Tambahkan opsi "UM" (Umum) di awal list
return [
{ title: 'UM - Umum', value: 'UM' },
...klinikList
];
});
// Sequential Patient ID Management
const PATIENT_ID_STORAGE_KEY = 'checkin_patient_id_counter';
const QUEUE_NUMBER_STORAGE_KEY = 'checkin_queue_number_counters';
const LAST_RESET_TIME_KEY = 'checkin_last_reset_time';
const RESET_HOUR = 22; // Jam 10 malam (22:00)
const HISTORY_STORAGE_KEY = 'checkin_history';
// Check and reset daily at 10 PM (22:00)
const checkAndResetDaily = () => {
if (typeof window === 'undefined') return;
const now = new Date();
const currentHour = now.getHours();
const currentDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
// Get last reset time
const lastResetTime = localStorage.getItem(LAST_RESET_TIME_KEY);
let lastResetDate = null;
if (lastResetTime) {
const lastReset = new Date(lastResetTime);
lastResetDate = lastReset.toISOString().split('T')[0];
}
// Check if it's 10 PM or later and hasn't been reset today
// Reset happens at 22:00 (10 PM) or later, but only once per day
const shouldReset = currentHour >= RESET_HOUR && lastResetDate !== currentDate;
if (shouldReset) {
// Reset counters to 0 (so next will be 0001)
localStorage.setItem(PATIENT_ID_STORAGE_KEY, '0');
localStorage.setItem(QUEUE_NUMBER_STORAGE_KEY, '{}');
// Clear check-in history (reset riwayat check-in)
checkInHistory.value = [];
if (typeof window !== 'undefined') {
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
// Save reset time
localStorage.setItem(LAST_RESET_TIME_KEY, now.toISOString());
console.log('✅ Daily reset executed at', now.toLocaleString('id-ID'), '- Counters and history reset');
}
};
// Generate sequential patient ID (P-00001, P-00002, etc.)
const generateSequentialPatientId = (): string => {
if (typeof window === 'undefined') return 'P-00001';
// Check and reset if needed (before generating)
checkAndResetDaily();
let counter = parseInt(localStorage.getItem(PATIENT_ID_STORAGE_KEY) || '0', 10);
counter += 1;
localStorage.setItem(PATIENT_ID_STORAGE_KEY, counter.toString());
return `P-${String(counter).padStart(5, '0')}`;
};
// Generate sequential queue number based on klinik code (AN-0001, AS-0001, etc.)
const generateSequentialQueueNumber = (kodeKlinik: string): string => {
if (typeof window === 'undefined') return `${kodeKlinik}-0001`;
// Check and reset if needed (before generating)
checkAndResetDaily();
const counters = JSON.parse(localStorage.getItem(QUEUE_NUMBER_STORAGE_KEY) || '{}');
let counter = parseInt(counters[kodeKlinik] || '0', 10);
counter += 1;
counters[kodeKlinik] = counter;
localStorage.setItem(QUEUE_NUMBER_STORAGE_KEY, JSON.stringify(counters));
return `${kodeKlinik}-${String(counter).padStart(4, '0')}`;
};
const snackbar = ref({
show: false,
title: '',
message: '',
color: '',
icon: '',
timeout: 4000,
});
// Check camera availability
const checkCameraAvailability = async () => {
cameraChecking.value = true;
hasCamera.value = false;
// Check if browser supports mediaDevices
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
console.error('navigator.mediaDevices is not supported');
showSnackbar('Error', 'Browser tidak mendukung akses kamera. Pastikan menggunakan HTTPS atau localhost.', 'error', 'mdi-camera-off');
cameraChecking.value = false;
return;
}
try {
// First, request permission by trying to get user media
// This is required for enumerateDevices to return device labels
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
// Stop the stream immediately after getting permission
stream.getTracks().forEach(track => track.stop());
// Now enumerate devices (will have labels after permission granted)
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
hasCamera.value = videoDevices.length > 0;
if (hasCamera.value) {
console.log(`Found ${videoDevices.length} camera(s):`, videoDevices.map(d => d.label || d.deviceId));
} else {
console.warn('No video input devices found');
showSnackbar('Warning', 'Tidak ada kamera yang terdeteksi', 'warning', 'mdi-camera-off');
}
} catch (err: any) {
console.error('Error checking camera:', err);
hasCamera.value = false;
let errorMessage = 'Gagal mengakses kamera. ';
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage += 'Izin kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.';
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage += 'Tidak ada kamera yang ditemukan.';
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
errorMessage += 'Kamera sedang digunakan aplikasi lain.';
} else if (err.name === 'OverconstrainedError' || err.name === 'ConstraintNotSatisfiedError') {
errorMessage += 'Kamera tidak memenuhi persyaratan.';
} else {
errorMessage += `Error: ${err.message || err.name}`;
}
showSnackbar('Error', errorMessage, 'error', 'mdi-camera-off');
} finally {
cameraChecking.value = false;
}
};
// Test camera function for debugging
const testCamera = async () => {
try {
showSnackbar('Info', 'Menguji akses kamera...', 'info', 'mdi-camera');
if (typeof navigator === 'undefined') {
showSnackbar('Error', 'navigator is undefined - pastikan di browser, bukan SSR', 'error', 'mdi-alert');
return;
}
if (!navigator.mediaDevices) {
showSnackbar('Error', 'navigator.mediaDevices is undefined - pastikan menggunakan HTTPS atau localhost', 'error', 'mdi-alert');
return;
}
if (!navigator.mediaDevices.getUserMedia) {
showSnackbar('Error', 'getUserMedia is not supported', 'error', 'mdi-alert');
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user',
width: { ideal: 1920, min: 1280 },
height: { ideal: 1080, min: 720 }
}
});
showSnackbar('Success', 'Kamera berhasil diakses!', 'success', 'mdi-check-circle');
// Stop stream after 2 seconds
setTimeout(() => {
stream.getTracks().forEach(track => track.stop());
}, 2000);
} catch (err: any) {
console.error('Test camera error:', err);
let errorMsg = `Error: ${err.name || 'Unknown'}`;
if (err.message) errorMsg += ` - ${err.message}`;
showSnackbar('Error', errorMsg, 'error', 'mdi-alert');
}
};
// QR Scanner Functions
const startScanning = async () => {
// Check camera first
if (!hasCamera.value) {
await checkCameraAvailability();
if (!hasCamera.value) {
showSnackbar('Error', 'Kamera tidak tersedia. Silakan gunakan input manual atau pastikan kamera terhubung.', 'error', 'mdi-camera-off');
return;
}
}
try {
// Set scanning state first to render the element
isScanning.value = true;
// Wait for DOM to update and element to be rendered
await nextTick();
// Wait a bit more to ensure element is fully rendered
await new Promise(resolve => setTimeout(resolve, 200));
// Check if element exists
const qrElement = document.getElementById(qrCodeId);
if (!qrElement) {
console.error(`Element with id "${qrCodeId}" not found in DOM`);
isScanning.value = false;
showSnackbar('Error', 'Element scanner tidak ditemukan. Silakan refresh halaman.', 'error', 'mdi-alert');
return;
}
// Cleanup existing instance if any (untuk handle refresh)
if (html5QrCode) {
try {
// Try to check if scanner is active and stop it
try {
if (html5QrCode.getState && html5QrCode.getState() === 2) { // Scanning state
await html5QrCode.stop();
await html5QrCode.clear();
}
} catch {
// If getState doesn't exist or fails, just try to stop
await html5QrCode.stop().catch(() => {});
await html5QrCode.clear().catch(() => {});
}
html5QrCode = null;
} catch (err) {
console.warn('Error cleaning up existing QR scanner:', err);
html5QrCode = null;
}
}
// Dynamic import html5-qrcode
if (!html5QrCode) {
const { Html5Qrcode: Html5QrcodeClass } = await import('html5-qrcode');
html5QrCode = new Html5QrcodeClass(qrCodeId);
console.log('Html5Qrcode initialized');
}
if (html5QrCode) {
cameraReady.value = false;
// Konfigurasi kamera untuk mobile dan desktop
const cameraConfig = isMobile.value
? { facingMode: "environment" } // Mobile: gunakan kamera belakang
: { facingMode: "user" }; // Desktop: gunakan kamera depan (webcam)
// Video constraints yang dioptimalkan untuk kualitas tinggi
const baseVideoConstraints = isMobile.value
? {
facingMode: "environment",
width: { ideal: 1280, min: 640, max: 1920 },
height: { ideal: 720, min: 480, max: 1080 }
}
: {
facingMode: "user",
width: { ideal: 1920, min: 1280, max: 1920 },
height: { ideal: 1080, min: 720, max: 1080 }
};
// Tambahkan advanced constraints hanya jika didukung
const videoConstraints: any = { ...baseVideoConstraints };
console.log('Starting QR scanner with config:', {
isMobile: isMobile.value,
cameraConfig,
videoConstraints
});
try {
await html5QrCode.start(
cameraConfig,
{
fps: isMobile.value ? 30 : 30, // FPS tinggi untuk deteksi lebih baik dan smooth
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
// QR box dengan ukuran lebih besar untuk deteksi lebih mudah
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Gunakan 80-90% dari viewfinder untuk area scanning yang lebih besar
const percentageSize = Math.floor(minEdgeSize * 0.80);
// Atau ukuran tetap yang lebih besar
const fixedSize = isMobile.value ? 250 : 250;
// Gunakan yang lebih besar antara percentage atau fixed
const qrboxSize = Math.max(percentageSize, fixedSize);
// Pastikan tidak melebihi viewfinder
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80);
console.log('QR Box size:', finalSize, 'Viewfinder:', viewfinderWidth, 'x', viewfinderHeight);
return {
width: Math.max(finalSize, 250), // Minimum 250px untuk deteksi lebih baik
height: Math.max(finalSize, 250)
};
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: videoConstraints,
rememberLastUsedCamera: true,
showTorchButtonIfSupported: true,
// Tambahkan opsi untuk meningkatkan deteksi
verbose: false // Set true untuk debugging
},
(decodedText: string, decodedResult: any) => {
// QR Code berhasil di-scan
console.log('✅ QR Code detected:', decodedText);
console.log('📊 Decoded result:', decodedResult);
// Validasi dan proses QR code
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText);
} else {
console.warn('Empty QR code detected');
}
},
(errorMessage: string) => {
// Log error untuk debugging - hanya log error penting
// Error ini biasanya muncul terus menerus saat tidak ada QR code yang terdeteksi
// Jadi kita filter untuk menghindari spam console
if (errorMessage) {
// Filter out common non-critical errors
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code parse error, error') &&
!errorMessage.includes('QR code decode error');
if (isCriticalError) {
console.warn('QR Scanner warning:', errorMessage);
}
}
}
);
// Set camera ready setelah sedikit delay untuk memastikan video sudah dimuat
setTimeout(() => {
cameraReady.value = true;
console.log('Camera ready, scanner is active');
}, 500);
showSnackbar('Info', 'Scanner aktif. Arahkan kamera ke QR code dan pastikan pencahayaan cukup', 'info', 'mdi-camera');
} catch (cameraError: any) {
console.error('Camera error:', cameraError);
// Jika kamera belakang tidak tersedia di mobile, coba kamera depan
if (isMobile.value && cameraError.name === 'NotFoundError') {
try {
console.log('Trying front camera as fallback...');
await html5QrCode.start(
{ facingMode: "user" },
{
fps: 30,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
const percentageSize = Math.floor(minEdgeSize * 0.85);
const fixedSize = 300;
const qrboxSize = Math.max(percentageSize, fixedSize);
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.95);
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
};
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
facingMode: "user",
width: { ideal: 1280, min: 640, max: 1920 },
height: { ideal: 720, min: 480, max: 1080 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (front camera):', decodedText);
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText);
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error');
if (isCriticalError) {
console.warn('QR Scanner (front) warning:', errorMessage);
}
}
}
);
setTimeout(() => {
cameraReady.value = true;
}, 500);
showSnackbar('Info', 'Menggunakan kamera depan. Arahkan QR code ke kamera', 'info', 'mdi-camera');
} catch (fallbackError: any) {
console.error('Fallback camera also failed:', fallbackError);
throw cameraError; // Throw original error
}
} else {
// Coba menggunakan deviceId langsung jika facingMode gagal
try {
console.log('Trying to get camera devices...');
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
if (videoDevices.length > 0) {
// Gunakan kamera pertama yang tersedia
const deviceId = videoDevices[0].deviceId;
console.log('Using device:', deviceId, videoDevices[0].label);
await html5QrCode.start(
{ deviceId: { exact: deviceId } },
{
fps: 30,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
const percentageSize = Math.floor(minEdgeSize * 0.80);
const fixedSize = isMobile.value ? 300 : 400;
const qrboxSize = Math.max(percentageSize, fixedSize);
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80);
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
};
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
deviceId: { exact: deviceId },
width: { ideal: isMobile.value ? 1280 : 1920, min: isMobile.value ? 640 : 1280, max: isMobile.value ? 1920 : 1920 },
height: { ideal: isMobile.value ? 720 : 1080, min: isMobile.value ? 480 : 720, max: isMobile.value ? 1080 : 1080 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (deviceId):', decodedText);
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText);
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error');
if (isCriticalError) {
console.warn('QR Scanner (deviceId) warning:', errorMessage);
}
}
}
);
setTimeout(() => {
cameraReady.value = true;
}, 500);
showSnackbar('Info', 'Scanner aktif menggunakan kamera yang tersedia', 'info', 'mdi-camera');
} else {
throw cameraError;
}
} catch (deviceError: any) {
console.error('DeviceId approach also failed:', deviceError);
throw cameraError;
}
}
}
}
} catch (err: any) {
console.error('Error starting scanner:', err);
isScanning.value = false;
cameraReady.value = false;
// Don't set hasCamera to false if it was detected, just log the error
let errorMessage = 'Gagal memulai scanner. ';
if (err.message && err.message.includes('not found')) {
errorMessage = 'Element scanner tidak ditemukan. Silakan refresh halaman dan coba lagi.';
console.error('Element not found error. Element ID:', qrCodeId);
} else if (err.name === 'NotAllowedError') {
errorMessage = 'Akses kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'Kamera tidak ditemukan. Silakan gunakan input manual.';
hasCamera.value = false;
} else if (err.name === 'NotReadableError') {
errorMessage = 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi lain yang menggunakan kamera.';
} else {
errorMessage += err.message || err.name || 'Unknown error';
}
showSnackbar('Error', errorMessage, 'error', 'mdi-alert');
}
};
const stopScanning = async () => {
try {
if (html5QrCode) {
// Stop scanner
if (isScanning.value) {
await html5QrCode.stop();
await html5QrCode.clear();
}
// Note: Media tracks are automatically stopped when html5QrCode.stop() is called
isScanning.value = false;
cameraReady.value = false;
showSnackbar('Info', 'Scanner dihentikan', 'info', 'mdi-camera-off');
}
} catch (err: any) {
console.error('Error stopping scanner:', err);
isScanning.value = false;
cameraReady.value = false;
// Force cleanup
html5QrCode = null;
}
};
const handleQRScanSuccess = (decodedText: string) => {
console.log('🎯 QR Scan Success! Data:', decodedText);
// Validasi data QR
if (!decodedText || decodedText.trim() === '') {
console.warn('QR Code data is empty');
showSnackbar('Error', 'QR Code tidak valid atau kosong', 'error', 'mdi-alert');
return;
}
// Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian)
if (isQRCodeAlreadyScanned(decodedText)) {
console.log('⏭️ Scan diabaikan: QR code sudah pernah berhasil di-scan');
showSnackbar('Peringatan', 'QR Code ini sudah pernah berhasil di-scan. Tidak dapat digunakan lagi untuk mencegah double antrian.', 'warning', 'mdi-alert-circle');
return;
}
// Debounce: cegah scan berulang dalam waktu singkat
const now = Date.now();
if (lastScannedQR === decodedText && (now - lastScanTime) < SCAN_DEBOUNCE_MS) {
console.log('⏭️ Scan diabaikan (debounce): QR code sama dalam waktu singkat');
return;
}
// Update last scan info
lastScannedQR = decodedText;
lastScanTime = now;
// Simpan data QR yang di-scan
scannedData.value = decodedText;
// Scanner tetap berjalan untuk memungkinkan scan QR code berikutnya
// Proses data QR
try {
onDetect(decodedText);
} catch (error) {
console.error('Error processing QR data:', error);
showSnackbar('Error', 'Gagal memproses data QR Code', 'error', 'mdi-alert');
}
// Simpan ke localStorage untuk riwayat
saveScannedQRData(decodedText);
};
// Simpan data QR yang di-scan ke localStorage
const saveScannedQRData = (qrData: string) => {
if (typeof window !== 'undefined') {
const scannedQRs = JSON.parse(localStorage.getItem('scanned_qr_data') || '[]');
scannedQRs.unshift({
data: qrData,
timestamp: new Date().toISOString(),
date: new Date().toLocaleDateString('id-ID'),
time: new Date().toLocaleTimeString('id-ID')
});
// Simpan maksimal 50 item
if (scannedQRs.length > 50) {
scannedQRs.pop();
}
localStorage.setItem('scanned_qr_data', JSON.stringify(scannedQRs));
}
};
// Load successful scans dari localStorage
const loadSuccessfulScans = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SUCCESSFUL_SCANS_KEY);
if (stored) {
try {
const scansArray = JSON.parse(stored);
successfulScans.value = new Set(scansArray);
} catch (e) {
console.error('Error loading successful scans:', e);
successfulScans.value = new Set();
}
}
}
};
// Simpan successful scan ke localStorage
const saveSuccessfulScan = (qrData: string) => {
if (typeof window !== 'undefined') {
successfulScans.value.add(qrData);
const scansArray = Array.from(successfulScans.value);
localStorage.setItem(SUCCESSFUL_SCANS_KEY, JSON.stringify(scansArray));
}
};
// Cek apakah QR code sudah pernah berhasil di-scan
const isQRCodeAlreadyScanned = (qrData: string): boolean => {
return successfulScans.value.has(qrData);
};
// Watch tab changes untuk stop scanner saat pindah tab
watch(tab, (newTab) => {
if (newTab !== 'checkin' && isScanning.value) {
stopScanning();
}
// Check camera when switching to checkin tab
if (newTab === 'checkin' && !hasCamera.value && !cameraChecking.value) {
checkCameraAvailability();
}
// Reset generate patient ID when switching to generate tab (to ensure fresh ID after reset)
if (newTab === 'generate') {
// Check and reset daily first
checkAndResetDaily();
// Generate new patient ID (will start from 00001 if reset happened)
generatePatientId.value = generateSequentialPatientId();
}
});
// Check camera on mount and initialize patient ID
onMounted(async () => {
if (typeof window !== 'undefined') {
// Wait for DOM to be ready
await nextTick();
// Reset tab to checkin (untuk handle refresh)
tab.value = 'checkin';
// Wait again to ensure tab is rendered
await nextTick();
// Reset state saat mount (untuk handle refresh)
isScanning.value = false;
cameraReady.value = false;
scannedData.value = null;
manualInput.value = '';
// Cleanup QR scanner instance jika masih ada dari refresh sebelumnya
if (html5QrCode) {
try {
if (isScanning.value) {
await html5QrCode.stop();
await html5QrCode.clear();
}
html5QrCode = null;
} catch (err) {
console.warn('Error cleaning up QR scanner on mount:', err);
html5QrCode = null;
}
}
// Clear any existing video streams (cleanup dari refresh sebelumnya)
// Note: We rely on html5QrCode cleanup to stop media tracks
// Initialize patient ID if empty
if (!generatePatientId.value) {
generatePatientId.value = generateSequentialPatientId();
}
// Set default klinik if available
if (!selectedKlinik.value && klinikOptions.value.length > 0) {
selectedKlinik.value = klinikOptions.value[0].value;
}
// Check camera availability
if (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;
}
// Load history untuk ditampilkan di sidebar
loadHistory();
// Check and reset daily at 10 PM
checkAndResetDaily();
// Set interval to check every minute for reset time
setInterval(() => {
checkAndResetDaily();
}, 60000); // Check every minute
} else {
hasCamera.value = false;
cameraChecking.value = false;
}
});
// Cleanup saat component unmount
onUnmounted(async () => {
// Stop scanning jika aktif
if (html5QrCode) {
try {
if (isScanning.value) {
await html5QrCode.stop();
await html5QrCode.clear();
}
// Destroy instance
html5QrCode = null;
} catch (err) {
console.warn('Error stopping scanner on unmount:', err);
html5QrCode = null;
}
}
// Note: Media tracks are automatically stopped when html5QrCode.stop() is called
// No need to manually stop tracks here
// Clear auto-close timer jika ada
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
// Reset state
isScanning.value = false;
cameraReady.value = false;
});
const onDetect = async (decodedText: string) => {
// Try to parse as QR code format: ID|STATUS
const parts = decodedText.split('|');
if (parts.length === 2) {
// QR Code format
const [patientId, status] = parts;
// Validasi format QR code
if (!patientId || !status) {
showSnackbar('Error', 'QR Code tidak valid. Format harus: ID_PASIEN|STATUS', 'error', 'mdi-close-circle');
return;
}
// Cek apakah pasien diperbolehkan check-in
const isAllowed = status === 'ALLOWED';
if (isAllowed) {
// Cari pasien berdasarkan barcode atau ID
const checkInResult = queueStore.checkInPatient(patientId);
if (checkInResult.success && checkInResult.patient) {
// Simpan hasil check-in
lastCheckInResult.value = {
success: true,
patientId: checkInResult.patient.barcode,
status: 'ALLOWED'
};
// Simpan ke history check-in
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian,
pembayaran: checkInResult.patient.pembayaran || 'N/A',
status: 'success',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
// Simpan QR code yang berhasil di-scan
saveSuccessfulScan(decodedText);
infoMessage.value = `✅ Check-in Berhasil!\n\nPasien ${checkInResult.patient.noAntrian.split(" |")[0]} berhasil melakukan check-in.`;
infoAction.value = 'checkin';
infoDialog.value = true;
} else {
// Simpan ke history dengan status failed jika check-in gagal
const cleanInput = String(patientId).trim().toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
if (p.barcode === cleanInput || p.barcode === patientId) return true;
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientId);
if (!isNaN(parsedNo) && p.no === parsedNo) return true;
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientId)) return true;
return false;
});
saveToHistory({
patientId: patientId,
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
infoMessage.value = `❌ Check-in Gagal!\n\n${checkInResult.message}`;
infoAction.value = 'checkin';
infoDialog.value = true;
}
} else {
// Jika belum diperbolehkan, cari data pasien untuk disimpan ke history
// Cari pasien dari queueStore berdasarkan patientId/barcode
const cleanInput = String(patientId).trim().toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
// Exact barcode match
if (p.barcode === cleanInput || p.barcode === patientId) {
return true;
}
// Try parsing as number
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientId);
if (!isNaN(parsedNo) && p.no === parsedNo) {
return true;
}
// Check if noAntrian includes the input
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientId)) {
return true;
}
return false;
});
// Simpan ke history dengan status NOT_ALLOWED
saveToHistory({
patientId: patientId,
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'NOT_ALLOWED',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
// Tampilkan pesan
lastCheckInResult.value = {
success: false,
patientId: patientId,
status: status
};
infoMessage.value = `⏳ Belum Diizinkan Check-in\n\nAntrean Pasien ${patientId} belum diperbolehkan check-in. Mohon menunggu hingga antrean Anda dipanggil.`;
infoAction.value = 'kembali';
infoDialog.value = true;
}
} else {
// Try as barcode directly
const checkInResult = queueStore.checkInPatient(decodedText);
if (checkInResult.success && checkInResult.patient) {
lastCheckInResult.value = {
success: true,
patientId: checkInResult.patient.barcode,
status: 'ALLOWED'
};
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian,
pembayaran: checkInResult.patient.pembayaran || 'N/A',
status: 'success',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
infoMessage.value = `✅ Check-in Berhasil!\n\nPasien ${checkInResult.patient.noAntrian.split(" |")[0]} berhasil melakukan check-in.`;
infoAction.value = 'checkin';
infoDialog.value = true;
} else {
// Simpan ke history dengan status failed jika check-in gagal
const cleanInput = String(decodedText).trim().toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
if (p.barcode === cleanInput || p.barcode === decodedText) return true;
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(decodedText);
if (!isNaN(parsedNo) && p.no === parsedNo) return true;
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(decodedText)) return true;
return false;
});
saveToHistory({
patientId: decodedText,
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
infoMessage.value = `❌ Check-in Gagal!\n\n${checkInResult.message}`;
infoAction.value = 'checkin';
infoDialog.value = true;
}
}
};
const handleInfoAction = async () => {
// Clear auto-close timer jika ada
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
infoDialog.value = false;
// Scanner tetap berjalan setelah dialog ditutup
// Ini memungkinkan user untuk segera memproses QR code berikutnya
// tanpa perlu memulai scanner lagi
};
// Watch infoDialog untuk auto-close setelah 3 detik
watch(infoDialog, (isOpen: boolean) => {
// Clear timer yang ada jika dialog ditutup
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
// Jika dialog dibuka, set timer untuk auto-close setelah 3 detik
if (isOpen) {
autoCloseTimer = setTimeout(() => {
infoDialog.value = false;
autoCloseTimer = null;
}, 5000); // 5 detik
}
});
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, barcode, atau ID pasien', 'error', 'mdi-alert');
return;
}
// Check-in menggunakan queueStore
const checkInResult = queueStore.checkInPatient(manualInput.value.trim());
if (checkInResult.success && checkInResult.patient) {
// Simpan ke history
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian,
pembayaran: checkInResult.patient.pembayaran || 'N/A',
status: 'success',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'Manual'
});
showSnackbar('Berhasil!', `Check-in manual berhasil. Pasien ${checkInResult.patient.noAntrian.split(" |")[0]} siap diproses.`, 'success', 'mdi-check-circle');
manualInput.value = '';
if (manualForm.value) {
(manualForm.value as any).reset();
}
} else {
// Simpan ke history dengan status failed jika check-in gagal
const cleanInput = String(manualInput.value.trim()).toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
if (p.barcode === cleanInput || p.barcode === manualInput.value.trim()) return true;
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(manualInput.value.trim());
if (!isNaN(parsedNo) && p.no === parsedNo) return true;
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(manualInput.value.trim())) return true;
return false;
});
saveToHistory({
patientId: manualInput.value.trim(),
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'Manual'
});
showSnackbar('Gagal!', checkInResult.message || 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle');
}
};
// Quick generate QR for testing
const generateQuickQR = (status: string) => {
// Check and reset daily first (before generating)
checkAndResetDaily();
// Auto-generate sequential patient ID
generatePatientId.value = generateSequentialPatientId();
generateStatus.value = status;
// Use first klinik if none selected
if (!selectedKlinik.value && klinikOptions.value.length > 0) {
selectedKlinik.value = klinikOptions.value[0].value;
}
generateQRCode();
};
// Generate QR Code function
const generateQRCode = async () => {
// Check and reset daily first (before generating)
checkAndResetDaily();
// Auto-generate patient ID if empty
if (!generatePatientId.value) {
generatePatientId.value = generateSequentialPatientId();
}
if (!selectedKlinik.value) {
showSnackbar('Error', 'Mohon pilih Klinik/Poli', '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 = '';
try {
// Use qrcode package that's already installed
const QRCode = (await import('qrcode')).default;
// Create QR code as data URL
const qrDataUrl = await QRCode.toDataURL(generatedQRData.value, {
errorCorrectionLevel: 'H', // High error correction for better scanning
type: 'image/png',
quality: 1,
margin: 2,
width: 300,
color: {
dark: '#000000', // Black
light: '#FFFFFF' // White
}
});
// Create img element and append to container
const img = document.createElement('img');
img.src = qrDataUrl;
img.alt = 'QR Code';
img.style.width = '100%';
img.style.maxWidth = '300px';
img.style.height = 'auto';
img.style.display = 'block';
img.style.margin = '0 auto';
qrContainer.appendChild(img);
console.log('QR Code created successfully:', generatedQRData.value);
showSnackbar('Berhasil!', 'QR Code berhasil di-generate. Silakan scan untuk testing', 'success', 'mdi-check-circle');
} catch (error) {
console.error('Error creating QR code:', error);
showSnackbar('Error', 'Gagal membuat QR Code. Silakan coba lagi', 'error', 'mdi-alert');
}
} else {
showSnackbar('Error', 'Container QR Code tidak ditemukan', 'error', 'mdi-alert');
}
};
const downloadQR = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement;
if (img && img.src) {
try {
// Convert img src (data URL) to blob
const response = await fetch(img.src);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const klinikCode = selectedKlinik.value || 'UNKNOWN';
const fileName = `QR-Test-${klinikCode}-${generatePatientId.value}-${generateStatus.value}-${Date.now()}.png`;
link.download = fileName;
link.href = url;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showSnackbar('Berhasil!', `QR Code berhasil didownload: ${fileName}`, 'success', 'mdi-download');
} catch (error) {
console.error('Download error:', error);
// Fallback: use img src directly
const link = document.createElement('a');
const klinikCode = selectedKlinik.value || 'UNKNOWN';
link.download = `QR-Test-${klinikCode}-${generatePatientId.value}-${generateStatus.value}.png`;
link.href = img.src;
link.click();
showSnackbar('Berhasil!', 'QR Code berhasil didownload', 'success', 'mdi-download');
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert');
}
};
const copyQRToClipboard = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement;
if (img && img.src) {
try {
// Convert img src to blob
const response = await fetch(img.src);
const blob = await response.blob();
try {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]);
showSnackbar('Berhasil!', 'QR Code berhasil disalin ke clipboard', 'success', 'mdi-content-copy');
} catch (err: any) {
console.error('Clipboard error:', err);
// Fallback: download instead
showSnackbar('Info', 'Copy ke clipboard tidak didukung. Gunakan tombol Download.', 'info', 'mdi-information');
}
} catch (error) {
console.error('Copy error:', error);
showSnackbar('Error', 'Gagal menyalin QR Code', 'error', 'mdi-alert');
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert');
}
};
const shareQR = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement;
if (img && img.src) {
try {
// Convert img src to blob
const response = await fetch(img.src);
const blob = await response.blob();
const klinikCode = selectedKlinik.value || 'UNKNOWN';
const file = new File([blob], `QR-Test-${klinikCode}-${generatePatientId.value}-${generateStatus.value}.png`, { type: 'image/png' });
if (navigator.share && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: 'QR Code Check-in Test',
text: `QR Code untuk testing: ${generatedQRData.value}`
});
showSnackbar('Berhasil!', 'QR Code berhasil dibagikan', 'success', 'mdi-share');
} catch (err: any) {
if (err.name !== 'AbortError') {
// Fallback to copy or download
copyQRToClipboard();
}
}
} else {
// Fallback: try copy to clipboard
copyQRToClipboard();
}
} catch (error) {
console.error('Share error:', error);
showSnackbar('Error', 'Gagal membagikan QR Code', 'error', 'mdi-alert');
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert');
}
};
// History Functions
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;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
kodeKlinik?: string;
}) => {
// Generate queue number based on klinik code if not provided
let queueNumber = item.queueNumber;
if (!queueNumber && item.kodeKlinik) {
queueNumber = generateSequentialQueueNumber(item.kodeKlinik);
} else if (!queueNumber) {
// Fallback to default if no klinik code
queueNumber = `ANT-${Date.now()}`;
}
const historyItem = {
...item,
queueNumber: queueNumber,
};
// Jika status baru adalah "success" atau "ALLOWED", cek apakah ada history lama dengan patientId yang sama
// yang masih berstatus "NOT_ALLOWED", "pending", atau "failed"
// Jika ada, hapus entry lama tersebut agar tidak terhitung sebagai "menunggu"
if (item.status === 'success' || item.status === 'ALLOWED') {
// Cari dan hapus entry lama dengan patientId yang sama yang masih menunggu
const waitingStatuses = ['NOT_ALLOWED', 'pending', 'failed'];
const indexToRemove = checkInHistory.value.findIndex(
(history: {
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
kodeKlinik?: string;
}) =>
history.patientId === item.patientId &&
waitingStatuses.includes(history.status)
);
if (indexToRemove !== -1) {
// Hapus entry lama yang masih menunggu
checkInHistory.value.splice(indexToRemove, 1);
console.log(`✅ Menghapus entry lama untuk pasien ${item.patientId} yang masih menunggu`);
}
}
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 openQRHistoryDialog = () => {
loadScannedQRHistory();
qrHistoryDialog.value = true;
};
const loadScannedQRHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('scanned_qr_data');
if (stored) {
try {
scannedQRHistory.value = JSON.parse(stored);
} catch (e) {
console.error('Error loading QR history:', e);
scannedQRHistory.value = [];
}
} else {
scannedQRHistory.value = [];
}
}
};
const clearQRHistory = () => {
scannedQRHistory.value = [];
if (typeof window !== 'undefined') {
localStorage.removeItem('scanned_qr_data');
}
showSnackbar('Berhasil', 'Semua riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const deleteQRHistoryItem = (index: number) => {
scannedQRHistory.value.splice(index, 1);
if (typeof window !== 'undefined') {
localStorage.setItem('scanned_qr_data', JSON.stringify(scannedQRHistory.value));
}
showSnackbar('Berhasil', 'Riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const useQRData = (qrData: string) => {
qrHistoryDialog.value = false;
scannedData.value = qrData;
onDetect(qrData);
};
const filteredQRHistory = computed(() => {
let filtered = [...scannedQRHistory.value];
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase();
filtered = filtered.filter(item =>
item.data.toLowerCase().includes(search)
);
}
return filtered;
});
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;
});
// Recent history untuk ditampilkan di sidebar (5 item terbaru)
// Hanya menampilkan riwayat dari hari ini setelah reset (setelah jam 10 malam)
const recentHistory = computed(() => {
const todayAfterReset = getTodayAfterReset();
// Filter hanya riwayat dari hari ini setelah reset
const todayHistory = checkInHistory.value.filter(item => {
const itemDate = new Date(item.checkInDate || item.checkInTime);
const itemDateStr = itemDate.toISOString().split('T')[0];
return itemDateStr === todayAfterReset;
});
return todayHistory.slice(0, 5);
});
const getStatusColor = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'success';
if (status === 'NOT_ALLOWED') return 'warning';
if (status === 'failed') return 'error';
return 'warning';
};
const getStatusIcon = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle';
if (status === 'NOT_ALLOWED') return 'mdi-clock-alert';
if (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') return 'Menunggu';
if (status === 'failed') return 'Gagal';
return 'Pending';
};
const getStatusClass = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'history-success';
if (status === 'NOT_ALLOWED') return 'history-pending';
if (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 formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
});
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
};
// Statistics computed properties
const statsToday = computed(() => {
const todayAfterReset = getTodayAfterReset();
return checkInHistory.value.filter(item => {
const itemDate = new Date(item.checkInDate || item.checkInTime);
const itemDateStr = itemDate.toISOString().split('T')[0];
return itemDateStr === todayAfterReset;
}).length;
});
// Get today's date after reset (after 10 PM, consider next day)
const getTodayAfterReset = (): string => {
const now = new Date();
const currentHour = now.getHours();
// If it's 10 PM or later, consider it as next day for stats
if (currentHour >= RESET_HOUR) {
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().split('T')[0];
}
return now.toISOString().split('T')[0];
};
const statsCompleted = computed(() => {
const todayAfterReset = getTodayAfterReset();
return checkInHistory.value.filter(item => {
const itemDate = new Date(item.checkInDate || item.checkInTime);
const itemDateStr = itemDate.toISOString().split('T')[0];
// Only count items from today (after reset time consideration)
if (itemDateStr !== todayAfterReset) return false;
return item.status === 'success' || item.status === 'ALLOWED';
}).length;
});
const statsWaiting = computed(() => {
// Ambil data menunggu dari queueStore untuk stage 'loket'
// Menghitung pasien dengan status 'menunggu' (belum dipanggil) atau 'waiting' (sudah dipanggil tapi belum check-in)
const loketPatients = queueStore.getPatientsByStage('loket');
const menungguPatients = loketPatients.value.menunggu || [];
const waitingPatients = loketPatients.value.waiting || [];
// Total pasien yang masih menunggu check-in (belum dipanggil + sudah dipanggil tapi belum check-in)
return menungguPatients.length + waitingPatients.length;
});
// Load history on mount
if (typeof window !== 'undefined') {
loadHistory();
loadScannedQRHistory();
loadSuccessfulScans();
}
</script>
<style scoped>
/* Modern Minimalist Background */
.bg-modern {
background: #ffffff;
min-height: 100vh;
max-height: 100vh;
position: relative;
overflow: hidden;
}
.no-scroll-container {
height: 100vh;
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
}
.no-scroll-container::-webkit-scrollbar {
width: 4px;
}
.no-scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.no-scroll-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.no-overflow {
overflow: hidden !important;
height: 100vh;
max-height: 100vh;
}
.bg-modern::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 50%, rgba(21, 101, 192, 0.05) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(21, 101, 192, 0.05) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* Main Card dengan Glassmorphism */
.main-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 24px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1) !important;
overflow: hidden;
position: relative;
z-index: 1;
}
/* Header Modern Minimalis */
.header-modern {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
padding: 8px 20px 6px;
position: relative;
overflow: hidden;
}
.header-modern::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
pointer-events: none;
}
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 0;
}
.icon-circle {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.header-text {
text-align: left;
}
.title-modern {
font-size: 18px;
font-weight: 700;
color: white;
margin: 0;
letter-spacing: -0.5px;
}
.subtitle-modern {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
margin: 1px 0 0;
font-weight: 400;
}
/* Tabs Modern */
.tabs-modern {
position: relative;
z-index: 1;
}
.tabs-modern :deep(.v-tab) {
text-transform: none;
font-weight: 500;
font-size: 12px;
letter-spacing: 0.2px;
color: rgba(255, 255, 255, 0.8);
min-width: auto;
padding: 0 16px;
transition: all 0.3s ease;
}
.tabs-modern :deep(.v-tab--selected) {
color: #FB8C00 !important;
background: rgba(251, 140, 0, 0.2);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.tabs-modern :deep(.v-tab--selected .v-icon) {
color: #FB8C00 !important;
}
.tabs-modern :deep(.v-tab--selected span) {
color: #FB8C00 !important;
}
.tabs-modern :deep(.v-slider) {
display: none;
}
.tab-modern {
display: flex;
align-items: center;
gap: 6px;
}
.tab-modern .v-icon {
font-size: 18px !important;
}
/* Content Area */
.content-modern {
background: white;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.content-modern::-webkit-scrollbar {
width: 4px;
}
.content-modern::-webkit-scrollbar-track {
background: transparent;
}
.content-modern::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.tab-content {
animation: fadeIn 0.4s ease-out;
height: 100%;
}
/* Two-column layout styling */
.checkin-wrapper {
width: 100%;
min-height: 100%;
}
.checkin-layout {
gap: 0 !important;
display: flex !important;
flex-wrap: wrap !important;
margin: 0 !important;
width: 100% !important;
}
.checkin-left-col {
padding-right: 16px !important;
border-right: 1px solid rgba(0, 0, 0, 0.08) !important;
display: flex !important;
flex-direction: column !important;
flex: 0 0 50% !important;
max-width: 50% !important;
}
.checkin-right-col {
padding-left: 16px !important;
display: flex !important;
flex-direction: column !important;
flex: 0 0 50% !important;
max-width: 50% !important;
}
@media (max-width: 959px) {
.checkin-layout {
flex-direction: column !important;
}
.checkin-left-col {
border-right: none !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
padding-right: 0 !important;
padding-bottom: 24px !important;
margin-bottom: 24px !important;
flex: 0 0 100% !important;
width: 100% !important;
max-width: 100% !important;
}
.checkin-right-col {
padding-left: 0 !important;
padding-top: 24px !important;
flex: 0 0 100% !important;
width: 100% !important;
max-width: 100% !important;
}
}
/* History Section Styling */
.history-section {
border-top: 1px solid rgba(0, 0, 0, 0.08);
padding-top: 16px;
}
.recent-history-list {
max-height: 400px;
overflow-y: auto;
}
.history-item-compact {
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.history-item-compact:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.history-item-compact.history-success {
border-left-color: #4caf50;
background: rgba(76, 175, 80, 0.03);
}
.history-item-compact.history-failed {
border-left-color: #f44336;
background: rgba(244, 67, 54, 0.03);
}
.history-item-compact.history-pending {
border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.03);
}
/* Gap utility untuk flex-wrap */
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
@media (min-width: 960px) {
.checkin-left-col,
.checkin-right-col {
flex: 0 0 50% !important;
max-width: 50% !important;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Status Header */
.status-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #e3f2fd 100%);
border-radius: 12px;
border: 1px solid rgba(21, 101, 192, 0.1);
text-align: center;
}
.status-icon-wrapper {
width: 40px;
height: 40px;
border-radius: 10px;
background: white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
margin: 0 auto;
}
.status-text {
width: 100%;
text-align: center;
}
.status-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 4px;
letter-spacing: -0.3px;
text-align: center;
}
.status-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
line-height: 1.4;
text-align: center;
}
/* QR Scanner Modern */
.qr-scanner-container {
display: flex;
justify-content: center;
align-items: center;
margin: 24px 0;
}
.qr-placeholder {
width: 100%;
max-width: 300px;
height: 300px;
background: linear-gradient(135deg, #f5f7fa 0%, #e3f2fd 100%);
border-radius: 16px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px dashed #1565C0;
box-shadow: 0 4px 20px rgba(21, 101, 192, 0.1);
margin: 0 auto;
}
.qr-reader-container {
width: 100%;
max-width: 500px;
margin: 0 auto;
position: relative;
}
.qr-reader-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
background: #000;
position: relative;
max-width: 500px;
max-height: 500px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(21, 101, 192, 0.2);
margin: 0 auto;
}
.qr-reader-wrapper :deep(video) {
width: 100% !important;
height: 100% !important;
border-radius: 16px;
display: block !important;
object-fit: cover;
background: #000;
aspect-ratio: 1 / 1;
transform: scaleX(-1); /* Mirror effect */
-webkit-transform: scaleX(-1); /* Safari support */
/* Reduce brightness to prevent overexposure/bloom */
filter: brightness(0.75) contrast(1.1);
-webkit-filter: brightness(0.75) contrast(1.1);
}
.qr-reader-wrapper :deep(canvas) {
display: none !important;
}
.qr-reader-wrapper :deep(#qr-reader__dashboard) {
display: none !important;
}
.qr-reader-wrapper :deep(#qr-reader__scan_region) {
border-radius: 16px;
border: 3px solid rgba(21, 101, 192, 0.8) !important;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4) !important;
position: relative;
}
.qr-reader-wrapper :deep(#qr-reader__scan_region::before) {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border: 3px solid rgba(21, 101, 192, 0.8);
border-radius: 16px;
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.02);
}
}
.qr-reader-wrapper :deep(#qr-reader__scan_region video) {
border-radius: 16px;
width: 100% !important;
height: 100% !important;
aspect-ratio: 1 / 1;
object-fit: cover;
transform: scaleX(-1); /* Mirror effect */
-webkit-transform: scaleX(-1); /* Safari support */
/* Reduce brightness to prevent overexposure/bloom */
filter: brightness(0.75) contrast(1.1);
-webkit-filter: brightness(0.75) contrast(1.1);
}
.scanner-status {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
font-size: 12px;
}
.scanner-instruction {
display: flex;
align-items: center;
justify-content: center;
margin-top: 12px;
padding: 10px 12px;
background: linear-gradient(135deg, rgba(21, 101, 192, 0.1) 0%, rgba(13, 71, 161, 0.1) 100%);
border-radius: 10px;
color: #1565C0;
font-weight: 500;
font-size: 12px;
text-align: center;
border: 1px solid rgba(21, 101, 192, 0.2);
}
.scanner-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
border-radius: 16px;
z-index: 10;
}
.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; }
}
/* Modern Buttons */
.btn-primary-modern {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%) !important;
color: white !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.3px;
border-radius: 12px !important;
padding: 14px 28px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(21, 101, 192, 0.3) !important;
}
.btn-primary-modern:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(21, 101, 192, 0.4) !important;
}
.btn-primary-modern:active {
transform: translateY(0);
}
.btn-primary-modern:disabled {
opacity: 0.5;
transform: none !important;
}
.btn-stop-modern {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.3px;
border-radius: 12px !important;
padding: 14px 28px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3) !important;
}
.btn-stop-modern:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4) !important;
}
.action-buttons {
margin-top: 20px;
display: flex;
justify-content: center;
}
.btn-centered {
width: auto !important;
min-width: 240px !important;
max-width: 320px !important;
margin: 0 auto !important;
display: block !important;
}
.btn-centered-small {
width: auto !important;
min-width: 180px !important;
margin: 0 auto;
display: block;
}
.btn-test-camera {
border-color: #1565C0 !important;
color: #1565C0 !important;
}
.btn-test-camera :deep(.v-btn__content) {
color: #1565C0 !important;
}
.btn-test-camera :deep(.v-icon) {
color: #1565C0 !important;
opacity: 1 !important;
}
/* Modern Inputs */
.input-modern :deep(.v-field) {
border-radius: 12px;
font-size: 15px;
background: #fafafa;
border: 1.5px solid #e5e7eb;
transition: all 0.3s ease;
}
.input-modern :deep(.v-field--focused) {
background: white;
border-color: #1565C0;
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.1);
}
.input-modern :deep(.v-field__input) {
padding-top: 12px;
padding-bottom: 12px;
}
.input-modern :deep(.v-label) {
font-weight: 500;
color: #6b7280;
}
.quick-actions {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.info-card {
display: flex;
justify-content: center;
}
.info-alert-centered {
width: 100%;
}
.info-alert-centered :deep(.v-alert__content) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
}
.quick-actions .v-btn {
transition: all 0.3s ease;
border-radius: 12px !important;
border: 1.5px solid #e5e7eb !important;
}
.quick-actions .v-btn:hover {
transform: translateY(-2px);
border-color: #1565C0 !important;
box-shadow: 0 4px 12px rgba(21, 101, 192, 0.15);
}
.quick-actions .v-btn :deep(.v-icon) {
opacity: 1 !important;
color: inherit !important;
}
.quick-actions .v-btn[color="primary"] {
color: #1565C0 !important;
border-color: #1565C0 !important;
}
.quick-actions .v-btn[color="primary"] :deep(.v-icon) {
color: #1565C0 !important;
}
.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%);
}
.error-header {
background: linear-gradient(135deg, #EF5350 0%, #E53935 100%);
}
.icon-bg-error {
background: linear-gradient(135deg, #EF5350 0%, #E53935 100%);
}
.dialog-button {
text-transform: none;
letter-spacing: 0.5px;
font-weight: 600;
transition: all 0.3s ease;
}
.dialog-button:hover {
transform: translateY(-2px);
}
/* Patient Info Card Styling */
.patient-info-card {
background: rgba(250, 250, 250, 0.8) !important;
border-color: rgba(0, 0, 0, 0.08) !important;
}
.patient-info-label {
color: #9e9e9e !important;
font-weight: 500;
opacity: 0.9;
}
.patient-info-value {
color: #757575 !important;
font-weight: 500 !important;
opacity: 0.85;
}
.patient-info-icon {
opacity: 0.7;
}
/* Stats Footer Modern */
.stats-footer-modern {
animation: slideUp 0.5s ease-out 0.3s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card-modern {
background: white;
border-radius: 12px;
padding: 16px 12px;
text-align: center;
border: 1px solid #e5e7eb;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.stat-card-modern::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%);
transform: scaleX(0);
transition: transform 0.3s ease;
}
.stat-card-modern:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #1565C0;
}
.stat-card-modern:hover::before {
transform: scaleX(1);
}
.stat-icon-modern {
margin-bottom: 12px;
display: inline-flex;
padding: 8px;
background: #e3f2fd;
border-radius: 10px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 4px;
letter-spacing: -0.5px;
}
.stat-label {
font-size: 11px;
color: #6b7280;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.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.no-scroll-container {
padding: 8px !important;
}
.main-card {
border-radius: 16px !important;
}
.header-modern {
padding: 6px 16px 4px !important;
}
.content-modern {
padding: 16px !important;
max-height: calc(100vh - 180px);
}
.header-content {
flex-direction: column;
text-align: center;
gap: 12px;
}
.header-text {
text-align: center;
}
.icon-circle {
width: 40px;
height: 40px;
}
.title-modern {
font-size: 16px;
}
.subtitle-modern {
font-size: 11px;
}
.content-modern {
padding: 20px 16px !important;
}
.status-header {
padding: 16px;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 12px;
}
.status-icon-wrapper {
margin: 0 auto;
}
.status-text {
text-align: center;
width: 100%;
}
.status-title {
font-size: 16px;
}
.status-subtitle {
font-size: 13px;
}
.qr-placeholder {
max-width: 100%;
height: 280px;
}
.qr-reader-container {
max-width: 100%;
}
.qr-reader-wrapper {
min-height: 300px;
max-height: 70vh;
border-radius: 16px;
}
.qr-reader-wrapper :deep(video) {
max-height: 70vh;
object-fit: contain;
transform: scaleX(-1); /* Mirror effect */
-webkit-transform: scaleX(-1); /* Safari support */
/* Reduce brightness to prevent overexposure/bloom */
filter: brightness(0.75) contrast(1.1);
-webkit-filter: brightness(0.75) contrast(1.1);
}
.scanner-instruction {
font-size: 12px;
padding: 12px;
}
.stats-footer-modern {
margin-top: 8px;
}
.stat-card-modern {
padding: 12px 8px;
}
.stat-value {
font-size: 18px;
}
.stat-label {
font-size: 10px;
}
.stat-icon-modern {
margin-bottom: 8px;
padding: 6px;
}
.tabs-modern :deep(.v-tab) {
font-size: 12px;
padding: 0 12px;
}
.tabs-modern :deep(.v-tab .v-icon) {
font-size: 18px;
}
.btn-primary-modern,
.btn-stop-modern {
padding: 12px 24px !important;
font-size: 14px;
min-width: 200px !important;
}
.btn-centered {
min-width: 200px !important;
max-width: 280px !important;
}
.qr-placeholder {
max-width: 100%;
height: 240px;
}
.qr-reader-wrapper {
min-height: 240px;
max-height: 280px;
}
}
/* Mobile Landscape */
@media (max-width: 900px) and (orientation: landscape) {
.qr-reader-wrapper {
min-height: 50vh;
max-height: 60vh;
}
.qr-reader-wrapper :deep(video) {
max-height: 60vh;
transform: scaleX(-1); /* Mirror effect */
-webkit-transform: scaleX(-1); /* Safari support */
/* Reduce brightness to prevent overexposure/bloom */
filter: brightness(0.75) contrast(1.1);
-webkit-filter: brightness(0.75) contrast(1.1);
}
}
</style>