Files
web-antrean/pages/CheckInPasien/checkIn.vue
T

7766 lines
217 KiB
Vue

<template>
<div>
<!-- Page Header Component -->
<PageHeader
logo="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
title="Check-In Pasien"
subtitle="Scan QR Code atau Input Manual untuk Check-In"
:show-add-button="true"
add-button-text="Generate QR"
theme="primary"
@add-click="showGenerateQRDialog = true"
/>
<div class="checkin-page-container">
<!-- Main Content Grid - Same pattern as AdminLoket.vue -->
<v-row class="content-grid" dense>
<!-- Left Column: QR Scan Card -->
<v-col cols="12" md="6" lg="7" class="sticky-column d-flex">
<div class="sticky-wrapper" style="width: 100%">
<v-card class="qr-scan-card" elevation="0" style="height: 100%">
<v-card-text class="pa-4 d-flex flex-column" style="height: 100%">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="20"
>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-3 compact-alert"
density="compact"
>
<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"
role="region"
aria-label="Area pemindaian QR code"
>
<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"
role="status"
aria-live="polite"
aria-atomic="true"
>
<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
>
<v-spacer></v-spacer>
<v-btn
icon
variant="text"
size="small"
:color="primaryColor"
@click="switchCamera"
title="Ganti Kamera"
>
<v-icon>mdi-camera-flip</v-icon>
</v-btn>
</div>
<div
id="qr-reader"
class="qr-reader-wrapper"
:class="{ 'is-front': isFrontCamera }"
role="region"
aria-label="Area pemindaian QR code"
aria-live="polite"
></div>
<!-- Hidden input for Photo Mode -->
<input
type="file"
ref="fileInput"
style="display: none"
accept="image/*"
@change="handleFileSelect"
/>
<!-- Moved overlay outside to prevent Vue/DOM conflict -->
<div class="scanner-loading-overlay" v-show="!cameraReady && isScanning">
<v-progress-circular
indeterminate
color="white"
size="48"
></v-progress-circular>
<p class="text-white mt-4">Memuat kamera...</p>
</div>
<div class="scanner-instruction">
<v-icon :color="primaryColor" size="16"
>mdi-information</v-icon
>
<span class="text-caption"
>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="x-large"
elevation="2"
@click="startScanning"
@keydown.enter="startScanning"
@keydown.space.prevent="startScanning"
:disabled="!hasCamera && !cameraChecking"
block
tabindex="0"
aria-label="Mulai scan QR code"
style="
font-weight: 600;
letter-spacing: 0.5px;
min-height: 56px;
"
>
<v-icon start size="24">mdi-camera</v-icon>
{{ hasCamera ? "Mulai Scan QR" : "Kamera Tidak Tersedia" }}
</v-btn>
<v-btn
v-else
class="btn-stop-modern btn-centered"
size="x-large"
elevation="2"
@click="stopScanning"
@keydown.enter="stopScanning"
@keydown.space.prevent="stopScanning"
block
tabindex="0"
aria-label="Stop scan QR code"
style="
font-weight: 600;
letter-spacing: 0.5px;
min-height: 56px;
"
>
<v-icon start size="24">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) - Hidden in production -->
<div v-if="false" 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>
</v-card-text>
</v-card>
</div>
</v-col>
<!-- Right Column: Manual Input Card -->
<v-col cols="12" md="6" lg="5" class="manual-input-column d-flex">
<v-card
class="manual-input-card"
elevation="0"
style="height: 100%; width: 100%"
>
<v-card-text class="pa-4 d-flex flex-column" style="height: 100%">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="secondaryColor" size="20"
>mdi-keyboard</v-icon
>
</div>
<div class="status-text">
<h3 class="status-title">Input Manual</h3>
<p class="status-subtitle">
Masukkan nomor Antrean atau Barcode Pasien
</p>
</div>
</div>
<v-form @submit.prevent="checkInManual" ref="manualForm">
<v-text-field
v-model="manualInput"
label="Nomor Antrean / Barcode Pasien"
placeholder="Contoh: RA020 atau 26011500001"
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="x-large"
type="submit"
elevation="2"
block
tabindex="0"
aria-label="Check-in sekarang dengan nomor antrean atau barcode pasien"
style="
font-weight: 600;
letter-spacing: 0.5px;
min-height: 56px;
"
@keydown.enter.prevent="checkInManual"
>
<v-icon start size="24">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-4">
<div class="d-flex align-center">
<div class="history-header-icon">
<v-icon size="24" :color="primaryColor"
>mdi-history</v-icon
>
</div>
<h4
class="text-h6 font-weight-bold ml-3"
style="color: #1a1a1a"
>
Riwayat Check-in
</h4>
</div>
<v-chip
size="small"
:color="
recentHistory.length > 0 ? 'primary' : 'grey-lighten-2'
"
variant="flat"
class="font-weight-semibold"
>
{{ recentHistory.length }} item
</v-chip>
</div>
<div
v-if="recentHistory.length > 0"
class="recent-history-list"
>
<!-- Info jumlah data -->
<div
v-if="recentHistory.length > recentHistoryItemsPerPage"
class="mb-2 text-caption text-grey-darken-1"
>
Menampilkan
{{
(recentHistoryPage - 1) * recentHistoryItemsPerPage + 1
}}
-
{{
Math.min(
recentHistoryPage * recentHistoryItemsPerPage,
recentHistory.length,
)
}}
dari {{ recentHistory.length }} riwayat
</div>
<v-card
v-for="(item, index) in paginatedRecentHistory"
:key="index"
variant="flat"
elevation="0"
class="mb-1 history-item-compact"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-1">
<div class="history-item-content">
<!-- Header Row: Status Badge & Time -->
<div
class="d-flex align-center justify-space-between mb-1"
>
<div class="d-flex align-center gap-1">
<div
class="history-status-badge"
:class="`status-${getStatusColor(item.status)}`"
>
<v-icon size="12">{{
getStatusIcon(item.status)
}}</v-icon>
</div>
<div>
<div
class="history-status-text"
:class="`text-${getStatusColor(item.status)}`"
>
{{ getStatusText(item.status) }}
</div>
<div class="history-method-text">
{{ item.method }}
</div>
</div>
</div>
<div class="history-time-badge">
<v-icon size="10" class="mr-1"
>mdi-clock-outline</v-icon
>
<span>{{ formatTime(item.checkInTime) }}</span>
</div>
</div>
<!-- Content Row: Klinik & Queue Number -->
<div
class="d-flex align-center justify-space-between mb-1 flex-wrap gap-1"
>
<div
v-if="item.klinik && item.klinik !== 'N/A'"
class="history-klinik-badge"
>
<v-icon size="10" class="mr-1"
>mdi-hospital-building</v-icon
>
<span>{{ item.klinik }}</span>
</div>
<div
v-if="item.klinikQueueNumber || item.queueNumber"
class="history-queue-badge"
>
<v-icon size="10" class="mr-1">mdi-ticket</v-icon>
<span class="font-weight-semibold">{{
item.klinikQueueNumber || item.queueNumber
}}</span>
</div>
</div>
<!-- Footer Row: Patient ID & Payment -->
<div
v-if="
item.patientId ||
(item.pembayaran && item.pembayaran !== 'N/A')
"
class="d-flex align-center flex-wrap gap-1 pt-1 border-top-history"
>
<div v-if="item.patientId" class="history-info-badge">
<v-icon size="10" class="mr-1"
>mdi-account-circle</v-icon
>
<span>{{ item.patientId }}</span>
</div>
<div
v-if="item.pembayaran && item.pembayaran !== 'N/A'"
class="history-info-badge"
>
<v-icon size="10" class="mr-1"
>mdi-credit-card</v-icon
>
<span>{{ item.pembayaran }}</span>
</div>
</div>
</div>
</v-card-text>
</v-card>
<!-- Pagination untuk recent history -->
<div
v-if="totalRecentHistoryPages > 1"
class="mt-3 d-flex justify-center"
>
<v-pagination
v-model="recentHistoryPage"
:length="totalRecentHistoryPages"
:total-visible="5"
color="primary"
size="small"
density="compact"
rounded="circle"
></v-pagination>
</div>
</div>
<div v-else class="empty-history-state text-center py-8">
<div class="empty-history-icon mb-4">
<v-icon size="64" color="grey-lighten-1"
>mdi-history</v-icon
>
</div>
<h5
class="text-subtitle-1 font-weight-semibold mb-2"
style="color: #4a5568"
>
Belum ada riwayat check-in
</h5>
<p class="text-body-2 text-grey mb-4">
Riwayat check-in Anda akan muncul di sini setelah melakukan
check-in
</p>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Stats Footer (Horizontal) - Permanent Footer for All Screens -->
<v-row class="stats-footer-row" dense>
<v-col cols="12">
<div class="stats-footer-horizontal">
<div
class="stat-item-footer-enhanced stat-item-footer-primary"
:title="`Total Check-in Hari Ini: ${todayStats.total}`"
>
<div class="stat-icon-footer-enhanced">
<v-icon :color="primaryColor" size="18"
>mdi-calendar-today</v-icon
>
</div>
<div class="stat-content-footer-enhanced">
<div class="stat-value-footer-enhanced">
{{ todayStats.total }}
</div>
<div class="stat-label-footer-enhanced">Hari Ini</div>
</div>
</div>
<div
class="stat-item-footer-enhanced stat-item-footer-success"
:title="`Check-in Berhasil: ${todayStats.success}`"
>
<div class="stat-icon-footer-enhanced">
<v-icon color="success" size="18">mdi-check-circle</v-icon>
</div>
<div class="stat-content-footer-enhanced">
<div class="stat-value-footer-enhanced">
{{ todayStats.success }}
</div>
<div class="stat-label-footer-enhanced">Berhasil</div>
</div>
</div>
<div
class="stat-item-footer-enhanced stat-item-footer-error"
:title="`Check-in Gagal: ${todayStats.failed}`"
>
<div class="stat-icon-footer-enhanced">
<v-icon color="error" size="18">mdi-close-circle</v-icon>
</div>
<div class="stat-content-footer-enhanced">
<div class="stat-value-footer-enhanced">
{{ todayStats.failed }}
</div>
<div class="stat-label-footer-enhanced">Gagal</div>
</div>
</div>
<div
class="stat-item-footer-enhanced stat-item-footer-info"
:title="`Check-in via QR: ${todayStats.qr}`"
>
<div class="stat-icon-footer-enhanced">
<v-icon color="info" size="18">mdi-qrcode-scan</v-icon>
</div>
<div class="stat-content-footer-enhanced">
<div class="stat-value-footer-enhanced">
{{ todayStats.qr }}
</div>
<div class="stat-label-footer-enhanced">QR Scan</div>
</div>
</div>
<div
class="stat-item-footer-enhanced stat-item-footer-warning"
:title="`Check-in Manual: ${todayStats.manual}`"
>
<div class="stat-icon-footer-enhanced">
<v-icon color="warning" size="18">mdi-keyboard</v-icon>
</div>
<div class="stat-content-footer-enhanced">
<div class="stat-value-footer-enhanced">
{{ todayStats.manual }}
</div>
<div class="stat-label-footer-enhanced">Manual</div>
</div>
</div>
</div>
</v-col>
</v-row>
<!-- Generate QR Dialog -->
<v-dialog v-model="showGenerateQRDialog" max-width="600px" scrollable>
<v-card>
<v-card-title class="text-h5 pa-4 bg-primary text-white">
<v-icon class="mr-2" color="white">mdi-qrcode</v-icon>
Generate QR Code untuk Testing
</v-card-title>
<v-card-text class="pa-6">
<!-- Status Header -->
<div class="mb-4">
<p class="text-body-2 text-grey">
Buat QR code yang bisa Anda scan untuk testing check-in
</p>
</div>
<!-- Quick Preset Buttons -->
<div class="mb-4">
<p class="text-caption text-grey text-center mb-2">
Quick Generate:
</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
color="primary"
size="small"
@click="generateQRCode"
class="text-none"
block
:disabled="!selectedKlinik"
>
<v-icon start size="16">mdi-qrcode-plus</v-icon>
Generate QR Code dengan Barcode Count
</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="mb-3"
density="comfortable"
:rules="[(v) => !!v || 'Klinik/Poli harus dipilih']"
hide-details="auto"
required
></v-select>
<v-select
v-model="selectedPembayaran"
label="Jenis Pembayaran"
:items="[
{ title: 'UMUM', value: 'UMUM' },
{ title: 'BPJS', value: 'BPJS' },
{ title: 'ASURANSI', value: 'ASURANSI' },
]"
prepend-inner-icon="mdi-credit-card"
variant="outlined"
:color="primaryColor"
class="mb-3"
density="comfortable"
hide-details="auto"
></v-select>
<v-text-field
v-model="generateBarcode"
label="Barcode"
placeholder="Auto-generate berdasarkan tanggal"
prepend-inner-icon="mdi-barcode"
variant="outlined"
:color="primaryColor"
class="mb-3"
density="comfortable"
readonly
hint="Barcode akan di-generate otomatis menggunakan format YYMMDD + 5 digit sequential"
persistent-hint
hide-details="auto"
></v-text-field>
<v-text-field
v-model="generateQueueNumber"
label="Nomor Antrean"
placeholder="Auto-generate berdasarkan Klinik dan Pembayaran"
prepend-inner-icon="mdi-ticket"
variant="outlined"
:color="primaryColor"
class="mb-4"
density="comfortable"
readonly
hint="Nomor Antrean akan di-generate otomatis dengan format R/E + Loket + 3 digit"
persistent-hint
hide-details="auto"
></v-text-field>
<div class="d-flex justify-center">
<v-btn
color="primary"
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="mt-6">
<v-card variant="outlined" class="pa-4">
<div class="text-center">
<p class="text-subtitle-2 text-grey mb-3">QR Code Tiket:</p>
<div id="qrcode" class="qr-code-container mb-4"></div>
<div v-if="generatedPatient" class="mb-3">
<v-chip color="primary" class="mb-2">
<v-icon start size="16">mdi-ticket</v-icon>
Nomor Antrean: {{ generateQueueNumber }}
</v-chip>
<v-chip color="info" class="mb-2 ml-2">
<v-icon start size="16">mdi-barcode</v-icon>
Barcode: {{ generateBarcode }}
</v-chip>
</div>
<p class="text-body-2 text-grey mb-4">
QR Code berisi: <strong>{{ generatedQRData }}</strong>
</p>
<!-- Action Buttons -->
<v-row dense>
<v-col cols="6">
<v-btn
color="primary"
variant="flat"
block
size="default"
@click="handlePrintTicket"
:loading="isPrinting"
:disabled="isPrinting || !generatedPatient"
class="text-none"
>
<v-icon start size="20">mdi-printer</v-icon>
{{ isPrinting ? "Mencetak..." : "Cetak Tiket" }}
</v-btn>
</v-col>
<v-col cols="3">
<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="3">
<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-row>
</div>
</v-card>
<!-- Instructions -->
<v-alert type="success" variant="tonal" class="mt-4 text-body-2">
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
<strong>Cara menggunakan:</strong>
<ol class="ml-4 mt-2">
<li>
Pilih <strong>Klinik/Poli</strong> dan
<strong>Jenis Pembayaran</strong>
</li>
<li>
Klik <strong>"Generate QR Code"</strong> untuk membuat QR
code dengan barcode count
</li>
<li>
Klik <strong>"Cetak Tiket"</strong> untuk mencetak tiket
menggunakan thermal printer
</li>
<li>
Atau gunakan <strong>"Download"</strong> untuk menyimpan QR
code sebagai gambar
</li>
<li>Scan QR code pada tiket untuk proses check-in</li>
</ol>
</v-alert>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn
color="grey"
variant="text"
@click="showGenerateQRDialog = false"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 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-6"
:class="
lastCheckInResult?.success && infoAction === 'checkin'
? 'success-header'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'warning-header'
: lastCheckInResult?.status === 'ALREADY_CHECKED_IN'
? 'warning-header'
: 'error-header'
"
>
<div class="icon-wrapper mb-3">
<div
class="icon-bg"
:class="
lastCheckInResult?.success && infoAction === 'checkin'
? 'icon-bg-success'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'icon-bg-warning'
: lastCheckInResult?.status === 'ALREADY_CHECKED_IN'
? 'icon-bg-warning'
: 'icon-bg-error'
"
>
<v-icon size="56" color="white" class="dialog-icon">
{{
lastCheckInResult?.success && infoAction === "checkin"
? "mdi-check-circle"
: lastCheckInResult?.status === "NOT_ALLOWED"
? "mdi-clock-alert"
: lastCheckInResult?.status === "ALREADY_CHECKED_IN"
? "mdi-check-circle"
: "mdi-close-circle"
}}
</v-icon>
</div>
</div>
<h2 class="text-h5 font-weight-bold text-white mb-2">
{{
lastCheckInResult?.success && infoAction === "checkin"
? "Check-in Berhasil!"
: lastCheckInResult?.status === "NOT_ALLOWED"
? "Belum Diizinkan"
: lastCheckInResult?.status === "ALREADY_CHECKED_IN"
? "Sudah Check-in"
: "Check-in Gagal"
}}
</h2>
<div class="status-badge mt-2">
<v-chip
:color="
lastCheckInResult?.success && infoAction === 'checkin'
? 'white'
: 'white'
"
:text-color="
lastCheckInResult?.success && infoAction === 'checkin'
? 'success'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'orange'
: lastCheckInResult?.status === 'ALREADY_CHECKED_IN'
? 'orange'
: 'error'
"
size="small"
class="font-weight-bold"
>
<v-icon start size="14">
{{
lastCheckInResult?.success && infoAction === "checkin"
? "mdi-check"
: lastCheckInResult?.status === "NOT_ALLOWED"
? "mdi-clock-alert"
: lastCheckInResult?.status === "ALREADY_CHECKED_IN"
? "mdi-check-circle"
: "mdi-close-circle"
}}
</v-icon>
{{
lastCheckInResult?.success && infoAction === "checkin"
? "Berhasil"
: lastCheckInResult?.status === "NOT_ALLOWED"
? "Menunggu"
: lastCheckInResult?.status === "ALREADY_CHECKED_IN"
? "Sudah Check-in"
: "Gagal"
}}
</v-chip>
</div>
</div>
<v-card-text class="pa-5">
<div class="message-container">
<div
class="text-body-1 font-weight-medium mb-4 text-center"
style="white-space: pre-line; color: #2d3748"
>
{{ infoMessage }}
</div>
<v-divider class="my-4"></v-divider>
<div
v-if="lastCheckInResult"
class="instruction-box pa-3 rounded-lg mb-4"
:class="
lastCheckInResult.success && infoAction === 'checkin'
? 'instruction-success'
: lastCheckInResult?.status === 'ALREADY_CHECKED_IN'
? 'instruction-warning'
: 'instruction-warning'
"
>
<div class="d-flex align-start">
<v-icon
:color="
lastCheckInResult.success && infoAction === 'checkin'
? 'success'
: lastCheckInResult?.status === 'ALREADY_CHECKED_IN'
? 'orange'
: 'orange'
"
class="mr-2 mt-1"
size="20"
>
{{
lastCheckInResult.success && infoAction === "checkin"
? "mdi-check-circle"
: lastCheckInResult?.status === "ALREADY_CHECKED_IN"
? "mdi-check-circle"
: "mdi-timer-sand"
}}
</v-icon>
<div>
<p class="text-body-2 font-weight-medium mb-1">
{{
lastCheckInResult.success && infoAction === "checkin"
? "Status Check-in:"
: "Status:"
}}
</p>
<p class="text-body-2 text-grey-darken-1 mb-0">
{{
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"
: lastCheckInResult.status === "ALREADY_CHECKED_IN"
? "Tiket ini sudah melakukan check-in sebelumnya. Tidak perlu check-in ulang."
: "Proses check-in gagal. Silakan coba lagi atau hubungi petugas."
}}
</p>
</div>
</div>
</div>
<!-- Patient Info Card -->
<v-card
variant="outlined"
class="patient-info-card"
elevation="0"
>
<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"
size="20"
>mdi-barcode</v-icon
>
<div>
<p class="text-caption patient-info-label mb-0">
Barcode Pasien
</p>
<p
class="text-body-2 patient-info-value mb-0 font-weight-medium"
>
{{
lastCheckInResult?.patientId ||
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 font-weight-medium"
>
{{ getCurrentTime() }}
</p>
</div>
</div>
</v-card-text>
</v-card>
</div>
</v-card-text>
<v-card-actions class="pa-3 pt-0">
<v-row dense>
<v-col v-if="infoAction === 'kembali'" cols="12">
<v-btn
color="grey-darken-1"
class="text-white font-weight-semibold text-none dialog-button"
size="large"
block
variant="flat"
@click="handleInfoAction"
elevation="0"
prepend-icon="mdi-arrow-left"
rounded="lg"
>
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-semibold text-none dialog-button"
size="large"
block
variant="flat"
@click="handleInfoAction"
elevation="2"
:prepend-icon="
lastCheckInResult?.success && infoAction === 'checkin'
? 'mdi-check'
: 'mdi-close'
"
rounded="lg"
>
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-5"
style="
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%);
"
>
<h2 class="text-h5 font-weight-bold text-white mb-1">
<v-icon color="white" class="mr-2" size="24">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-3">
<!-- Header Stats -->
<div class="history-header-stats mb-3">
<div class="history-header-title mb-2">
<h3
class="text-subtitle-1 font-weight-bold d-flex align-center"
>
<v-icon size="20" class="mr-2" color="primary"
>mdi-history</v-icon
>
Riwayat Check-in
</h3>
</div>
<v-row dense>
<v-col cols="6" md="3">
<div class="stat-card-header">
<div class="stat-icon-header stat-icon-total">
<v-icon size="20">mdi-history</v-icon>
</div>
<div class="stat-content-header">
<div class="stat-value-header">
{{ checkInHistory.length }}
</div>
<div class="stat-label-header">Total</div>
</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="stat-card-header">
<div class="stat-icon-header stat-icon-success">
<v-icon size="20">mdi-check-circle</v-icon>
</div>
<div class="stat-content-header">
<div class="stat-value-header">
{{
checkInHistory.filter(
(item) =>
item.status === "success" ||
item.status === "ALLOWED",
).length
}}
</div>
<div class="stat-label-header">Berhasil</div>
</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="stat-card-header">
<div class="stat-icon-header stat-icon-failed">
<v-icon size="20">mdi-close-circle</v-icon>
</div>
<div class="stat-content-header">
<div class="stat-value-header">
{{
checkInHistory.filter(
(item) =>
item.status === "failed" ||
item.status === "NOT_ALLOWED",
).length
}}
</div>
<div class="stat-label-header">Gagal</div>
</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="stat-card-header">
<div class="stat-icon-header stat-icon-filtered">
<v-icon size="20">mdi-filter</v-icon>
</div>
<div class="stat-content-header">
<div class="stat-value-header">
{{ filteredHistory.length }}
</div>
<div class="stat-label-header">Tersaring</div>
</div>
</div>
</v-col>
</v-row>
</div>
<!-- Filter dan Search -->
<div class="mb-3">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="historySearch"
label="Cari ID Pasien, Nomor Antrean, atau Klinik"
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">
<!-- Info jumlah data -->
<div class="mb-2 text-body-2 text-grey-darken-1">
Menampilkan {{ (historyPage - 1) * historyItemsPerPage + 1 }} -
{{
Math.min(
historyPage * historyItemsPerPage,
filteredHistory.length,
)
}}
dari {{ filteredHistory.length }} riwayat
</div>
<div class="history-list">
<v-card
v-for="(item, index) in paginatedHistory"
:key="index"
variant="flat"
elevation="0"
class="mb-1 history-item-dialog"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-2">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<!-- Header: Status & Method -->
<div class="d-flex align-center mb-1 gap-1">
<div
class="history-status-badge-dialog"
:class="`status-${getStatusColor(item.status)}`"
>
<v-icon size="14">{{
getStatusIcon(item.status)
}}</v-icon>
</div>
<div>
<div
class="history-status-text-dialog"
:class="`text-${getStatusColor(item.status)}`"
>
{{ getStatusText(item.status) }}
</div>
<div class="history-method-text-dialog">
{{ item.method }}
</div>
</div>
</div>
<!-- Queue Number -->
<div
v-if="item.klinikQueueNumber || item.queueNumber"
class="mb-1"
>
<div class="history-queue-badge-dialog">
<v-icon size="12" class="mr-1">mdi-ticket</v-icon>
<span class="font-weight-semibold">{{
item.klinikQueueNumber || item.queueNumber
}}</span>
</div>
</div>
<!-- Info Badges: Patient ID, Klinik & Pembayaran -->
<div class="d-flex flex-wrap gap-1 mb-1">
<div
v-if="item.patientId"
class="history-info-badge-dialog"
>
<v-icon size="12" class="mr-1"
>mdi-account-circle</v-icon
>
<span>{{ item.patientId }}</span>
</div>
<div
v-if="item.klinik && item.klinik !== 'N/A'"
class="history-klinik-badge-dialog"
>
<v-icon size="12" class="mr-1"
>mdi-hospital-building</v-icon
>
<span>{{ item.klinik }}</span>
</div>
<div
v-if="item.pembayaran"
class="history-payment-badge-dialog"
>
<v-icon size="12" class="mr-1"
>mdi-credit-card</v-icon
>
<span>{{ item.pembayaran }}</span>
</div>
</div>
<!-- Time Info -->
<div class="d-flex flex-wrap gap-1">
<div class="history-time-badge-dialog">
<v-icon size="10" class="mr-1"
>mdi-clock-outline</v-icon
>
<span>{{ formatDateTime(item.checkInTime) }}</span>
</div>
<div
v-if="item.checkInDate"
class="history-time-badge-dialog"
>
<v-icon size="10" class="mr-1">mdi-calendar</v-icon>
<span>{{ formatDate(item.checkInDate) }}</span>
</div>
</div>
</div>
<div class="ml-2">
<v-btn
icon
size="small"
variant="text"
color="error"
@click="deleteHistoryItem(index)"
class="history-delete-btn"
>
<v-icon size="18">mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Pagination -->
<div
v-if="totalHistoryPages > 1"
class="mt-3 d-flex justify-center"
>
<v-pagination
v-model="historyPage"
:length="totalHistoryPages"
:total-visible="7"
color="primary"
rounded="circle"
></v-pagination>
</div>
</div>
<!-- Empty State -->
<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-h6 text-grey mb-1">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-3 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-semibold text-none"
size="large"
variant="flat"
@click="historyDialog = false"
prepend-icon="mdi-close"
rounded="lg"
elevation="2"
>
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-5"
style="
background: linear-gradient(135deg, #fb8c00 0%, #e65100 100%);
"
>
<h2 class="text-h5 font-weight-bold text-white mb-1">
<v-icon color="white" class="mr-2" size="24"
>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-3">
<!-- Header Stats -->
<div class="history-header-stats mb-3">
<div class="history-header-title mb-2">
<h3
class="text-subtitle-1 font-weight-bold d-flex align-center"
>
<v-icon size="20" class="mr-2" color="secondary"
>mdi-qrcode-scan</v-icon
>
Riwayat QR Scan
</h3>
</div>
<v-row dense>
<v-col cols="6" md="4">
<div class="stat-card-header">
<div class="stat-icon-header stat-icon-total">
<v-icon size="20">mdi-qrcode-scan</v-icon>
</div>
<div class="stat-content-header">
<div class="stat-value-header">
{{ scannedQRHistory.length }}
</div>
<div class="stat-label-header">Total Scan</div>
</div>
</div>
</v-col>
<v-col cols="6" md="4">
<div class="stat-card-header">
<div class="stat-icon-header stat-icon-filtered">
<v-icon size="20">mdi-filter</v-icon>
</div>
<div class="stat-content-header">
<div class="stat-value-header">
{{ filteredQRHistory.length }}
</div>
<div class="stat-label-header">Tersaring</div>
</div>
</div>
</v-col>
<v-col cols="12" md="4">
<v-btn
color="error"
variant="outlined"
block
@click="clearQRHistory"
:disabled="scannedQRHistory.length === 0"
class="mt-2 mt-md-0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- Filter dan Search -->
<div class="mb-3">
<v-row dense>
<v-col cols="12">
<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-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="flat"
elevation="0"
class="mb-2 history-item-dialog history-item-qr"
>
<v-card-text class="pa-2">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<!-- QR Data -->
<div class="mb-1">
<div class="history-qr-badge-dialog">
<v-icon size="14" class="mr-1">mdi-qrcode</v-icon>
<span class="font-weight-semibold">{{
item.data
}}</span>
</div>
</div>
<!-- Time Info -->
<div class="d-flex flex-wrap gap-1">
<div class="history-time-badge-dialog">
<v-icon size="10" class="mr-1"
>mdi-clock-outline</v-icon
>
<span>{{ item.time }}</span>
</div>
<div class="history-time-badge-dialog">
<v-icon size="10" class="mr-1">mdi-calendar</v-icon>
<span>{{ item.date }}</span>
</div>
</div>
</div>
<div class="ml-2 d-flex gap-1">
<v-btn
icon
size="small"
variant="text"
color="primary"
@click="useQRData(item.data)"
class="history-action-btn"
>
<v-icon size="18">mdi-refresh</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
color="error"
@click="deleteQRHistoryItem(index)"
class="history-delete-btn"
>
<v-icon size="18">mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-6">
<v-icon size="48" color="grey-lighten-1" class="mb-2"
>mdi-qrcode-scan</v-icon
>
<p class="text-h6 text-grey mb-1">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-3 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-semibold text-none"
size="large"
variant="flat"
@click="qrHistoryDialog = false"
prepend-icon="mdi-close"
rounded="lg"
elevation="2"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
import { useQueueStore } from "@/stores/queueStore";
import { useMasterStore } from "@/stores/masterStore";
import { useWebSocket } from "@/composables/useWebSocket";
import { useThermalPrint } from "@/composables/useThermalPrint";
import PageHeader from "@/components/common/PageHeader.vue";
definePageMeta({
// middleware:['auth'],
layout: false,
});
const queueStore = useQueueStore();
const masterStore = useMasterStore();
const { printTicketFromPatient, isPrinting } = useThermalPrint();
const config = useRuntimeConfig();
const wsBaseUrl =
config.public?.wsBaseUrl || "ws://10.10.150.100:8084/api/v1/ws";
// 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 showGenerateQRDialog = ref(false);
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);
const isProcessingScan = ref(false); // Flag untuk mencegah scan ganda saat sedang proses
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
const currentFacingMode = ref<"user" | "environment">("environment");
const isFrontCamera = computed(() => currentFacingMode.value === "user");
const fileInput = ref<HTMLInputElement | null>(null);
const isProcessingFile = ref(false);
// 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 historyPage = ref(1);
const historyItemsPerPage = ref(10);
const checkInHistory = ref<
Array<{
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
klinik?: string;
kodeKlinik?: 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 generateBarcode = ref("");
const generateQueueNumber = ref("");
const generateStatus = ref("ALLOWED");
const generatedQRData = ref("");
const selectedKlinik = ref("");
const selectedPembayaran = ref("UMUM");
const generatedPatient = ref<any>(null);
const statusOptions = [
{ title: "Diizinkan Check-in", value: "ALLOWED" },
{ title: "Belum Diizinkan", value: "NOT_ALLOWED" },
];
// WebSocket stabilization - version 1.1
// Generate a unique session suffix (random ID)
const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '')
const checkInClientId = computed(() => {
return `checkin-${uniqueSessionSuffix.value}`
})
const fetchAllData = async () => {
console.log('🔄 CheckIn refresh: Syncing data...');
try {
await queueStore.fetchAllPatients();
queueStore.ensureInitialData();
checkAndResetDaily();
console.log('✅ CheckIn refresh: Success');
} catch (err) {
console.error('❌ CheckIn refresh error:', err);
}
};
const isConnected = computed(() => queueStore.isWsConnected);
// Watch for clientId changes and reconnect if needed
watch(checkInClientId, (newClientId, oldClientId) => {
if (newClientId && newClientId !== oldClientId) {
console.log('🔄 Client ID changed, reconnecting centralized WebSocket...')
queueStore.initWebSocket(newClientId)
}
})
// 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 = 2; // Jam 2 pagi
const HISTORY_STORAGE_KEY = "checkin_history";
// Check and reset daily at 2 AM (02: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 dinamis
const cameraConfig = { facingMode: currentFacingMode.value };
// Video constraints yang dioptimalkan
const baseVideoConstraints = {
facingMode: currentFacingMode.value,
width: { ideal: 1920, min: 1280 },
height: { ideal: 1080, min: 720 },
};
// 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: 20, // Increased for better responsiveness
qrbox: (viewfinderWidth: number, viewfinderHeight: number) => {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Set to 72% for optimal balance between target size and quiet zone
const qrboxSize = Math.floor(minEdgeSize * 0.72);
return {
width: Math.max(qrboxSize, 250),
height: Math.max(qrboxSize, 250),
};
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: videoConstraints,
rememberLastUsedCamera: true,
showTorchButtonIfSupported: true,
useBarCodeDetectorIfSupported: true, // Native scanning if available
verbose: false, // Set true for 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: 20,
qrbox: (viewfinderWidth: number, viewfinderHeight: number) => {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Optimized 72%
const qrboxSize = Math.floor(minEdgeSize * 0.72);
return {
width: Math.max(qrboxSize, 250),
height: Math.max(qrboxSize, 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,
useBarCodeDetectorIfSupported: 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: 20,
qrbox: (viewfinderWidth: number, viewfinderHeight: number) => {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Optimized 72%
const qrboxSize = Math.floor(minEdgeSize * 0.85);
return {
width: Math.max(qrboxSize, 250),
height: Math.max(qrboxSize, 250),
};
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
deviceId: { exact: deviceId },
width: { ideal: isMobile.value ? 1280 : 1920 },
height: { ideal: isMobile.value ? 720 : 1080 },
},
rememberLastUsedCamera: true,
useBarCodeDetectorIfSupported: 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) {
// Immediate state update
const scanner = html5QrCode;
html5QrCode = null;
isScanning.value = false;
cameraReady.value = false;
// Stop and clear
try {
if (scanner.getState && scanner.getState() > 1) { // 1 is stopped, 2 is scanning, 3 is paused
await scanner.stop();
}
await scanner.clear();
} catch (err) {
console.warn("Scanner shutdown error (non-critical):", err);
}
showSnackbar("Info", "Scanner dihentikan", "info", "mdi-camera-off");
}
} catch (err: any) {
console.error("Error in stopScanning:", err);
isScanning.value = false;
cameraReady.value = false;
html5QrCode = null;
}
};
const handleQRScanSuccess = (decodedText: string) => {
console.log("🎯 QR Scan Success! Data:", decodedText);
// Guard: Jika sedang proses atau dialog sedang terbuka, abaikan scan
if (isProcessingScan.value || infoDialog.value) {
console.log("⏭️ Scan diabaikan: Sedang memproses atau dialog terbuka");
return;
}
// 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;
}
// Tandai sedang proses
isProcessingScan.value = true;
// CATATAN: Scanner tetap berjalan (tidak di-stop) atas permintaan user,
// namun guard di atas (isProcessingScan || infoDialog) akan mencegah
// deteksi ganda selama proses berlangsung atau dialog terbuka.
console.log("📸 Scanner remains active, guards will handle over-detection");
// Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian)
// Hanya abaikan jika benar-benar sudah berhasil check-in sebelumnya (status di-loket)
if (isQRCodeAlreadyScanned(decodedText)) {
// Verifikasi ulang: cek apakah pasien benar-benar sudah check-in di queueStore
const cleanInput = String(decodedText).trim();
const patientInStore = (queueStore.allPatients as any[]).find((p) => {
const patientBarcode = String(p.barcode || "").trim();
return (
patientBarcode === cleanInput ||
patientBarcode.toUpperCase() === cleanInput.toUpperCase()
);
});
if (patientInStore && patientInStore.status === "di-loket") {
// Pasien benar-benar sudah check-in, abaikan scan
console.log(
"⏭️ Scan diabaikan: QR code sudah pernah berhasil di-scan dan pasien sudah di-loket",
);
showSnackbar(
"Peringatan",
"QR Code ini sudah pernah berhasil di-scan. Tidak dapat digunakan lagi untuk mencegah double antrian.",
"warning",
"mdi-alert-circle",
);
// Reset processing flag jika dihentikan di sini
isProcessingScan.value = false;
return;
} else {
// Pasien belum benar-benar check-in, mungkin ada masalah di data sebelumnya
// Biarkan scan berlanjut untuk verifikasi ulang
console.warn(
"⚠️ QR code marked as scanned but patient not in di-loket status, allowing re-scan for verification",
);
// Hapus dari successful scans untuk memungkinkan scan ulang
if (
typeof window !== "undefined" &&
successfulScans.value.has(decodedText)
) {
successfulScans.value.delete(decodedText);
const scansArray = Array.from(successfulScans.value);
localStorage.setItem(SUCCESSFUL_SCANS_KEY, JSON.stringify(scansArray));
}
}
}
// Debounce: cegah scan berulang dalam waktu singkat (fallback jika stopScanning gagal cepat)
const now = Date.now();
if (lastScannedQR === decodedText && now - lastScanTime < SCAN_DEBOUNCE_MS) {
console.log(
"⏭️ Scan diabaikan (debounce): QR code sama dalam waktu singkat",
);
isProcessingScan.value = false;
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 is async, so we need to handle it properly
onDetect(decodedText).catch((error) => {
console.error("Error processing QR data in onDetect:", error);
// Reset processing flag jika terjadi error di onDetect
isProcessingScan.value = false;
// Hanya tampilkan error message
showSnackbar(
"Error",
`Gagal memproses data QR Code: ${error.message || "Error tidak diketahui"}`,
"error",
"mdi-alert",
);
// Simpan ke history sebagai failed
saveToHistory({
patientId: decodedText,
queueNumber: null as any,
klinikQueueNumber: null as any,
pembayaran: "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: "N/A",
kodeKlinik: null as any,
});
});
} catch (error) {
console.error("Error processing QR data:", error);
// Reset processing flag jika terjadi error sync
isProcessingScan.value = false;
showSnackbar(
"Error",
`Gagal memproses data QR Code: ${error instanceof Error ? error.message : "Error tidak diketahui"}`,
"error",
"mdi-alert",
);
// Simpan ke history sebagai failed
saveToHistory({
patientId: decodedText,
queueNumber: null as any,
klinikQueueNumber: null,
pembayaran: "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: "N/A",
kodeKlinik: null,
});
}
// Simpan ke localStorage untuk riwayat (selalu simpan, baik berhasil atau gagal)
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").replace(/\./g, ":"),
});
// 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 data when switching to generate tab
if (newTab === "generate") {
// Check and reset daily first
checkAndResetDaily();
// Clear generated data
generateBarcode.value = "";
generateQueueNumber.value = "";
generatedQRData.value = "";
generatedPatient.value = null;
}
});
// 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
// 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;
}
// Wait for stores to be hydrated and ready
await nextTick();
// Load history untuk ditampilkan di sidebar
loadHistory();
// Initial fetch/sync
await fetchAllData();
// Initialize WebSocket (Centralized)
queueStore.initWebSocket(checkInClientId.value);
// Register global interest to receive staggered bulk refreshes on generic WS messages
queueStore.registerGlobalInterest();
// Set interval to check every minute for reset time
const resetCheckInterval = setInterval(() => {
checkAndResetDaily();
}, 60000); // Check every minute
onUnmounted(() => {
clearInterval(resetCheckInterval);
// Unregister global interest
queueStore.unregisterGlobalInterest();
});
} 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;
}
// WebSocket is now global, we might not want to disconnect here if other tabs use it.
// if (wsInstance) wsInstance.disconnect(); // Old logic
// Reset state
isScanning.value = false;
cameraReady.value = false;
});
// Helper function untuk extract barcode/patientId dari format QR code
// Handle format: "barcode" atau "patientId|status" (contoh: "P-00001|ALLOWED")
// Returns: { barcode: string, status?: string }
const extractQRData = (
qrData: string,
): { barcode: string; status?: string } => {
const cleanData = String(qrData || "").trim();
// Check jika format "patientId|status" (untuk testing QR code)
if (cleanData.includes("|")) {
const parts = cleanData.split("|");
const patientId = parts[0]?.trim() || "";
const status = parts[1]?.trim();
return {
barcode: patientId,
status: status,
};
}
// Format thermal print: hanya barcode saja
return {
barcode: cleanData,
};
};
// Helper function untuk extract kode dan angka dari noAntrian
// Contoh: "UM0014 | Onsite - ..." -> { kode: "UM", angka: "0014" }
// Contoh: "F-RA001 | Onsite - ..." -> { kode: "RA", angka: "001" } (handle "F-" prefix)
const extractKodeAndAngka = (
noAntrian: string,
): { kode: string; angka: string } | null => {
if (!noAntrian) return null;
// Handle format "F-RA001" by matching after "F-" prefix if exists
const match = noAntrian.toUpperCase().match(/^(?:F-)?([A-Z]+)(\d+)/);
if (match) {
return { kode: match[1], angka: match[2] };
}
return null;
};
// Helper function untuk membandingkan input dengan noAntrian (kode + angka harus match)
// Ini mencegah false positive ketika angka sama tapi kode beda (misalnya UM0014 vs AN0014)
// Handle format "F-RA001" dengan menghapus prefix "F-" untuk comparison
const matchNoAntrian = (input: string, noAntrian: string): boolean => {
if (!input || !noAntrian) return false;
const cleanInput = String(input).trim().toUpperCase();
const noAntrianUpper = String(noAntrian).toUpperCase();
// Remove "F-" prefix from both input and noAntrian for comparison
const cleanInputWithoutPrefix = cleanInput.replace(/^F-/, "");
const noAntrianWithoutPrefix = noAntrianUpper.replace(/^F-/, "");
// 1. Extract kode + angka dari noAntrian (misalnya "F-RA001 | ..." -> kode: "RA", angka: "001")
const noAntrianParts = extractKodeAndAngka(noAntrian);
if (!noAntrianParts) {
// Jika noAntrian tidak punya format kode+angka, gunakan exact match saja
// Check both with and without prefix
return (
noAntrianUpper === cleanInput ||
noAntrianWithoutPrefix === cleanInputWithoutPrefix
);
}
// 2. Extract kode + angka dari input (misalnya "F-RA001" atau "RA001" -> kode: "RA", angka: "001")
const inputParts = extractKodeAndAngka(cleanInput);
// 3. Jika input punya kode + angka, HARUS match kode dan angka
if (inputParts) {
// Kode HARUS sama (case-insensitive sudah di-handle oleh extractKodeAndAngka)
if (inputParts.kode !== noAntrianParts.kode) {
return false; // Kode berbeda, tidak match
}
// Jika kode sama, bandingkan angka
return (
inputParts.angka === noAntrianParts.angka ||
inputParts.angka ===
noAntrianParts.angka.padStart(inputParts.angka.length, "0") ||
noAntrianParts.angka ===
inputParts.angka.padStart(noAntrianParts.angka.length, "0")
);
}
// 4. Jika input hanya angka (tidak punya kode), JANGAN match
// Ini mencegah match "002" dengan "UM1002" atau "AN002" dengan "UM1002"
const inputAngka = cleanInputWithoutPrefix.replace(/[^0-9]/g, "");
if (inputAngka && inputAngka === cleanInputWithoutPrefix) {
// Input hanya angka, tidak match karena tidak ada kode
return false;
}
// 5. Exact match untuk kasus lain (jika input mengandung kode tapi tidak ter-extract)
// Check both with and without prefix
if (
noAntrianUpper === cleanInput ||
noAntrianWithoutPrefix === cleanInputWithoutPrefix
) {
return true;
}
// 6. Check jika noAntrian dimulai dengan input + spasi atau pipe (untuk partial match yang aman)
// Contoh: "F-RA001" match dengan "F-RA001 | ..." atau "RA001" match dengan "F-RA001 | ..."
// Tapi TIDAK match dengan "UM1002" karena kode berbeda
if (noAntrianParts && inputParts) {
// Jika kedua-duanya punya kode+angka, hanya match jika kode sama
if (inputParts.kode === noAntrianParts.kode) {
// Check jika noAntrian dimulai dengan format "F-KODEANGKA |" atau "KODEANGKA | ..."
const noAntrianPrefix = `${noAntrianParts.kode}${noAntrianParts.angka}`;
const inputPrefix = `${inputParts.kode}${inputParts.angka}`;
// Pastikan input prefix sama dengan noAntrian prefix
if (inputPrefix === noAntrianPrefix) {
// Check jika noAntrian dimulai dengan prefix ini (with or without "F-") diikuti spasi, pipe, atau end of string
if (noAntrianWithoutPrefix.startsWith(noAntrianPrefix)) {
const nextChar = noAntrianWithoutPrefix[noAntrianPrefix.length];
if (!nextChar || nextChar === " " || nextChar === "|") {
return true;
}
}
// Also check with "F-" prefix
if (noAntrianUpper.startsWith(`F-${noAntrianPrefix}`)) {
const nextChar = noAntrianUpper[`F-${noAntrianPrefix}`.length];
if (!nextChar || nextChar === " " || nextChar === "|") {
return true;
}
}
}
}
}
return false;
};
// --- FUNGSI TAMBAHAN SCANNER ---
/**
* Berpindah antara kamera depan dan belakang
*/
const switchCamera = async () => {
if (!isScanning.value) return;
const newMode = currentFacingMode.value === "user" ? "environment" : "user";
console.log(`Switching camera to: ${newMode}`);
// Stop scanner dulu
if (html5QrCode) {
try {
await html5QrCode.stop();
} catch (err) {
console.warn("Error stopping for camera switch:", err);
}
}
// Update mode dan restart
currentFacingMode.value = newMode;
cameraReady.value = false;
await startScanning();
};
/**
* Trigger pemilih file (Photo Mode)
*/
const triggerFileSelect = () => {
if (fileInput.value) {
fileInput.value.click();
}
};
/**
* Handle scan dari file gambar (Photo Mode)
*/
const handleFileSelect = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
isProcessingFile.value = true;
try {
// Pastikan scanner ter-inisialisasi
if (!html5QrCode) {
const { Html5Qrcode: Html5QrcodeClass } = await import("html5-qrcode");
html5QrCode = new Html5QrcodeClass(qrCodeId);
}
console.log("Processing file scan...");
const decodedText = await html5QrCode.scanFile(file, true);
if (decodedText) {
console.log("✅ QR Code detected from file:", decodedText);
handleQRScanSuccess(decodedText);
}
} catch (err) {
console.error("Error scanning file:", err);
showSnackbar(
"Error",
"Gagal mendeteksi QR code dari foto. Pastikan foto jelas dan terang.",
"error",
"mdi-alert-circle",
);
} finally {
isProcessingFile.value = false;
// Reset input agar bisa pilih file yang sama lagi jika perlu
if (input) input.value = "";
}
};
const onDetect = async (decodedText: string) => {
// Guard tambahan: Jika sedang proses, abaikan
if (!isProcessingScan.value && !infoDialog.value) {
// Jika dipanggil langsung bukan dari handleQRScanSuccess, set processing flag
isProcessingScan.value = true;
console.log("📸 onDetect: Processing started (scanner remains active)...");
}
// Format QR Code yang didukung:
// 1. Format thermal print: hanya BARCODE saja (contoh: 250811100163)
// 2. Format testing: "patientId|status" (contoh: P-00001|ALLOWED)
// Status akan dicek real-time dari queueStore saat scan QR
console.log("🔍 onDetect called with QR data:", decodedText);
// Extract barcode/patientId dari QR data (handle format testing dengan pipe)
const qrData = extractQRData(decodedText);
const searchBarcode = qrData.barcode; // Barcode/patientId untuk dicari di queueStore
// Clean input - remove whitespace and normalize
const cleanInput = String(searchBarcode).trim();
const cleanInputUpper = cleanInput.toUpperCase();
console.log("🔍 Extracted barcode/patientId:", searchBarcode);
console.log("🔍 Cleaned input:", cleanInput);
console.log("📊 Total patients in store:", queueStore.allPatients.length);
// IMPORTANT: Hanya cari dengan EXACT barcode match untuk menghindari false positive
// Format barcode: YYMMDD + 5 digit (contoh: 26011500001)
// Jangan gunakan fallback ke noAntrian atau no karena bisa menyebabkan false positive
// Contoh masalah: RA020 dengan barcode 26011500001 terdeteksi sebagai RA002 dengan barcode 26011500198
const foundPatient = (queueStore.allPatients as any[]).find((p) => {
if (!p) return false;
// Normalize barcode untuk comparison (remove whitespace, case-insensitive)
const patientBarcode = String(p.barcode || "").trim();
// EXACT barcode match (case-insensitive, whitespace-insensitive)
// Ini adalah satu-satunya cara yang aman untuk match pasien
if (
patientBarcode === cleanInput ||
patientBarcode.toLowerCase() === cleanInput.toLowerCase()
) {
console.log(
"✅ Found by exact barcode match:",
patientBarcode,
"===",
cleanInput,
);
return true;
}
return false;
});
if (!foundPatient) {
// Tiket belum di-generate (tidak ada di queueStore)
console.error("❌ Patient not found. QR data:", decodedText);
console.error("🔍 Extracted barcode/patientId:", searchBarcode);
console.error(
"📋 Available barcodes (first 10):",
queueStore.allPatients.slice(0, 10).map((p) => ({
no: p.no,
barcode: p.barcode,
noAntrian: p.noAntrian?.split(" |")[0],
})),
);
saveToHistory({
patientId: searchBarcode || decodedText,
queueNumber: null,
klinikQueueNumber: null,
pembayaran: "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
});
const errorMsg = `❌ Tiket Belum Di-generate!\n\nQR Code: ${decodedText}\nBarcode/Patient ID: ${searchBarcode}\n\nTiket belum terdaftar di sistem. Pastikan tiket sudah di-generate terlebih dahulu.`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Error",
"Tiket belum terdaftar di sistem. Pastikan tiket sudah di-generate.",
"error",
"mdi-alert-circle",
);
return;
}
// CATATAN: Validasi processStage dihapus karena terlalu ketat
// Yang penting adalah status pasien (waiting/pending), bukan processStage
// Pasien yang sudah dipanggil (status: waiting) harus bisa check-in
// meskipun processStage-nya bukan 'loket' (bisa 'klinik' atau 'penunjang')
// Validasi processStage akan dilakukan di checkInPatient() di queueStore
// yang akan mengecek status pasien secara real-time
// IMPORTANT: Refresh data pasien dari queueStore untuk mendapatkan status REAL-TIME
// Jangan gunakan foundPatient.status karena mungkin sudah stale
// Cari ulang pasien dari queueStore dengan EXACT barcode match
const freshPatient = (queueStore.allPatients as any[]).find((p) => {
const patientBarcode = String(p.barcode || "").trim();
// Hanya exact barcode match untuk menghindari false positive
return (
patientBarcode === foundPatient.barcode ||
patientBarcode === searchBarcode ||
patientBarcode === cleanInput
);
});
if (!freshPatient) {
console.error("❌ Patient not found in queueStore after refresh");
saveToHistory({
patientId: foundPatient.barcode,
queueNumber: foundPatient.noAntrian,
klinikQueueNumber:
foundPatient.noAntrian?.split(" |")[0] || foundPatient.noAntrian,
pembayaran: foundPatient.pembayaran || "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: foundPatient.klinik || "N/A",
kodeKlinik: foundPatient.kodeKlinik || null,
});
const errorMsg = `❌ Pasien tidak ditemukan di sistem setelah refresh data.`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Error",
"Pasien tidak ditemukan di sistem",
"error",
"mdi-alert-circle",
);
return;
}
// Gunakan data pasien yang fresh untuk validasi
const patientStatus = freshPatient.status;
const klinikQueueNumber =
freshPatient.noAntrian?.split(" |")[0] || freshPatient.noAntrian || "N/A";
console.log("🔍 Fresh patient status:", {
barcode: freshPatient.barcode,
noAntrian: freshPatient.noAntrian,
status: patientStatus,
processStage: freshPatient.processStage,
});
// Cek apakah sudah check-in (status === 'di-loket') - gunakan data fresh
if (patientStatus === "di-loket") {
// Sudah check-in sebelumnya
saveToHistory({
patientId: freshPatient.barcode,
queueNumber: freshPatient.noAntrian,
klinikQueueNumber: klinikQueueNumber,
pembayaran: freshPatient.pembayaran || "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: freshPatient.klinik || "N/A",
kodeKlinik: freshPatient.kodeKlinik || null,
});
lastCheckInResult.value = {
success: false,
patientId: freshPatient.barcode,
status: "ALREADY_CHECKED_IN",
};
const errorMsg = `⚠️ Sudah Check-in!\n\nNomor Antrean ${klinikQueueNumber} sudah melakukan check-in sebelumnya.`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Warning",
`Tiket ${klinikQueueNumber} sudah check-in sebelumnya`,
"warning",
"mdi-check-circle",
);
return;
}
if (patientStatus === "menunggu") {
// Status "menunggu" = belum dipanggil oleh admin loket, belum muncul di AntrianLoket
// TIDAK BOLEH check-in
saveToHistory({
patientId: freshPatient.barcode,
queueNumber: freshPatient.noAntrian,
klinikQueueNumber: klinikQueueNumber,
pembayaran: freshPatient.pembayaran || "N/A",
status: "NOT_ALLOWED",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: freshPatient.klinik || "N/A",
kodeKlinik: freshPatient.kodeKlinik || null,
});
lastCheckInResult.value = {
success: false,
patientId: freshPatient.barcode,
status: "NOT_ALLOWED",
};
const errorMsg = `⏳ Belum Diizinkan Check-in\n\nNomor Antrean ${klinikQueueNumber} belum dipanggil oleh admin loket. Mohon menunggu hingga nomor antrean Anda dipanggil dan muncul di layar Antrian Loket.`;
infoMessage.value = errorMsg;
infoAction.value = "kembali";
infoDialog.value = true;
showSnackbar(
"Info",
`Tiket ${klinikQueueNumber} belum dipanggil. Mohon menunggu.`,
"info",
"mdi-clock-alert",
);
return;
}
// Status "waiting" atau "pending" → BOLEH check-in
// Panggil checkInPatient untuk validasi dan update status ke "di-loket"
// Gunakan barcode pasien yang fresh, bukan decodedText (untuk memastikan format benar)
const patientBarcodeForCheckIn =
freshPatient.barcode || searchBarcode || decodedText;
console.log(
"🔍 Calling checkInPatient with barcode (fresh):",
patientBarcodeForCheckIn,
);
console.log(
"🔍 Original QR data:",
decodedText,
"| Extracted barcode:",
searchBarcode,
);
const checkInResult = await queueStore.checkInPatient(
patientBarcodeForCheckIn,
);
if (checkInResult.success && checkInResult.patient) {
// Check-in berhasil
const successKlinikQueueNumber =
checkInResult.patient.noAntrian?.split(" |")[0] ||
checkInResult.patient.noAntrian ||
"N/A";
lastCheckInResult.value = {
success: true,
patientId: checkInResult.patient.barcode,
status: "ALLOWED",
};
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: successKlinikQueueNumber,
pembayaran: checkInResult.patient.pembayaran || "N/A",
status: "success",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: checkInResult.patient.klinik || "N/A",
kodeKlinik: checkInResult.patient.kodeKlinik || null,
});
saveSuccessfulScan(decodedText);
const successMsg = `✅ Check-in Berhasil!\n\nPasien ${successKlinikQueueNumber} berhasil melakukan check-in dan status berubah menjadi di loket.`;
infoMessage.value = successMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Berhasil",
`Check-in berhasil. Tiket ${successKlinikQueueNumber} siap diproses.`,
"success",
"mdi-check-circle",
);
} else {
// Check-in gagal (misalnya validasi di checkInPatient gagal)
saveToHistory({
patientId: foundPatient.barcode,
queueNumber: foundPatient.noAntrian,
klinikQueueNumber: klinikQueueNumber,
pembayaran: foundPatient.pembayaran || "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "QR Scan",
klinik: foundPatient.klinik || "N/A",
kodeKlinik: foundPatient.kodeKlinik || null,
});
lastCheckInResult.value = {
success: false,
patientId: foundPatient.barcode,
status: "FAILED",
};
const errorMsg = `❌ Check-in Gagal!\n\n${checkInResult.message || "Status pasien tidak memungkinkan untuk check-in."}`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Gagal",
checkInResult.message || "Check-in gagal dilakukan",
"error",
"mdi-close-circle",
);
}
};
const handleInfoAction = async () => {
// Clear auto-close timer jika ada
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
infoDialog.value = false;
// Reset processing flag agar bisa scan lagi
isProcessingScan.value = false;
// Scanner sudah berjalan (tidak dihentikan), jadi tidak perlu startScanning() 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;
}
const inputValue = manualInput.value.trim();
// Extract barcode/patientId dari input (handle format testing dengan pipe)
const qrData = extractQRData(inputValue);
const searchBarcode = qrData.barcode; // Barcode/patientId untuk dicari di queueStore
const cleanInput = String(searchBarcode).trim();
const cleanInputUpper = cleanInput.toUpperCase();
const originalInput = inputValue.toUpperCase().trim();
console.log("🔍 checkInManual called with:", inputValue);
console.log("🔍 Extracted barcode/patientId:", searchBarcode);
console.log("🔍 Cleaned input:", cleanInput);
console.log("🔍 Original input:", originalInput);
console.log("📊 Total patients in store:", queueStore.allPatients.length);
let foundPatient: any = null;
let freshPatient: any = null;
try {
// Cari pasien dengan dua metode:
// ...
foundPatient = queueStore.allPatients.find((p) => {
try {
if (!p) return false;
// Normalize barcode untuk comparison (remove whitespace, case-insensitive)
const patientBarcode = String(p.barcode || "").trim();
// 1. EXACT barcode match (case-insensitive, whitespace-insensitive) - PRIORITAS TERTINGGI
if (
patientBarcode === cleanInput ||
patientBarcode.toLowerCase() === cleanInput.toLowerCase()
) {
console.log(
"✅ Found by exact barcode match:",
patientBarcode,
"===",
cleanInput,
);
return true;
}
// 2. EXACT nomor antrean match (untuk manual input)
// Menggunakan matchNoAntrian helper untuk memastikan kode + angka match dengan benar
if (p.noAntrian && typeof matchNoAntrian === "function") {
if (matchNoAntrian(originalInput, p.noAntrian)) {
console.log(
"✅ Found by exact nomor antrean match:",
p.noAntrian,
"===",
originalInput,
);
return true;
}
}
} catch (innerErr) {
console.warn("⚠️ Error in patient find loop for patient:", p, innerErr);
}
return false;
});
if (!foundPatient) {
// Tiket belum di-generate (tidak ada di queueStore)
console.error("❌ Patient not found. Input:", inputValue);
console.error("🔍 Extracted barcode/patientId:", searchBarcode);
console.error(
"📋 Available patients (first 10):",
queueStore.allPatients.slice(0, 10).map((p) => ({
no: p.no,
barcode: p.barcode,
noAntrian: p.noAntrian?.split(" |")[0],
})),
);
saveToHistory({
patientId: searchBarcode || inputValue,
queueNumber: null,
klinikQueueNumber: null,
pembayaran: "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "Manual",
klinik: "N/A",
kodeKlinik: null,
});
// RESET lastCheckInResult for failure to avoid success color bleed
lastCheckInResult.value = {
success: false,
patientId: searchBarcode || inputValue,
status: "NOT_FOUND",
};
const errorMsg = `❌ Tiket Tidak Ditemukan!\n\nInput: ${inputValue}\n\nTiket dengan nomor antrean atau barcode tersebut tidak ditemukan di sistem.\n\nPastikan:\n- Nomor antrean atau barcode sudah benar\n- Tiket sudah di-generate terlebih dahulu\n- Format input: RA020, F-RA001, atau 26011500001`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Error",
"Tiket tidak ditemukan. Pastikan nomor antrean atau barcode sudah benar.",
"error",
"mdi-alert-circle",
);
return;
}
// CATATAN: Validasi processStage dihapus karena terlalu ketat
// Yang penting adalah status pasien (waiting/pending), bukan processStage
// Pasien yang sudah dipanggil (status: waiting) harus bisa check-in
// meskipun processStage-nya bukan 'loket' (bisa 'klinik' atau 'penunjang')
// Validasi processStage akan dilakukan di checkInPatient() di queueStore
// yang akan mengecek status pasien secara real-time
// IMPORTANT: Refresh data pasien dari queueStore untuk mendapatkan status REAL-TIME
// Jangan gunakan foundPatient.status karena mungkin sudah stale
// Cari ulang pasien dari queueStore dengan EXACT match (barcode atau nomor antrean)
freshPatient = queueStore.allPatients.find((p) => {
if (!p) return false;
// Match by barcode
const patientBarcode = String(p.barcode || "").trim();
if (
foundPatient && (
patientBarcode === foundPatient.barcode ||
patientBarcode === searchBarcode ||
patientBarcode === cleanInput
)
) {
return true;
}
// Match by nomor antrean (jika input adalah nomor antrean)
if (foundPatient && p.noAntrian && p.no === foundPatient.no) {
return true;
}
return false;
});
if (!freshPatient) {
console.error("❌ Patient not found in queueStore after refresh");
saveToHistory({
patientId: foundPatient.barcode,
queueNumber: foundPatient.noAntrian,
klinikQueueNumber:
foundPatient.noAntrian?.split(" |")[0] || foundPatient.noAntrian,
pembayaran: foundPatient.pembayaran || "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "Manual",
klinik: foundPatient.klinik || "N/A",
kodeKlinik: foundPatient.kodeKlinik || null,
});
// RESET lastCheckInResult for failure
lastCheckInResult.value = {
success: false,
patientId: foundPatient.barcode,
status: "REFRESH_NOT_FOUND",
};
const errorMsg = `❌ Pasien tidak ditemukan di sistem setelah refresh data.`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Error",
"Pasien tidak ditemukan di sistem",
"error",
"mdi-alert-circle",
);
return;
}
// Gunakan data pasien yang fresh untuk validasi
const patientStatus = freshPatient.status;
const klinikQueueNumber =
freshPatient.noAntrian?.split(" |")[0] || freshPatient.noAntrian || "N/A";
console.log("🔍 Fresh patient status:", {
barcode: freshPatient.barcode,
noAntrian: freshPatient.noAntrian,
status: patientStatus,
processStage: freshPatient.processStage,
});
// Cek apakah sudah check-in (status === 'di-loket') - gunakan data fresh
if (patientStatus === "di-loket") {
// Sudah check-in sebelumnya
saveToHistory({
patientId: freshPatient.barcode,
queueNumber: freshPatient.noAntrian,
klinikQueueNumber: klinikQueueNumber,
pembayaran: freshPatient.pembayaran || "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "Manual",
klinik: freshPatient.klinik || "N/A",
kodeKlinik: freshPatient.kodeKlinik || null,
});
lastCheckInResult.value = {
success: false,
patientId: freshPatient.barcode,
status: "ALREADY_CHECKED_IN",
};
const errorMsg = `⚠️ Sudah Check-in!\n\nNomor Antrean ${klinikQueueNumber} sudah melakukan check-in sebelumnya.`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Warning",
`Tiket ${klinikQueueNumber} sudah check-in sebelumnya`,
"warning",
"mdi-check-circle",
);
return;
}
if (patientStatus === "menunggu") {
// Status "menunggu" = belum dipanggil oleh admin loket, belum muncul di AntrianLoket
// TIDAK BOLEH check-in
saveToHistory({
patientId: freshPatient.barcode,
queueNumber: freshPatient.noAntrian,
klinikQueueNumber: klinikQueueNumber,
pembayaran: freshPatient.pembayaran || "N/A",
status: "NOT_ALLOWED",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "Manual",
klinik: freshPatient.klinik || "N/A",
kodeKlinik: freshPatient.kodeKlinik || null,
});
lastCheckInResult.value = {
success: false,
patientId: freshPatient.barcode,
status: "NOT_ALLOWED",
};
const errorMsg = `⏳ Belum Diizinkan Check-in\n\nNomor Antrean ${klinikQueueNumber} belum dipanggil oleh admin loket. Mohon menunggu hingga nomor antrean Anda dipanggil dan muncul di layar Antrian Loket.`;
infoMessage.value = errorMsg;
infoAction.value = "kembali";
infoDialog.value = true;
showSnackbar(
"Info",
`Tiket ${klinikQueueNumber} belum dipanggil. Mohon menunggu.`,
"info",
"mdi-clock-alert",
);
return;
}
// Status "waiting" atau "pending" → BOLEH check-in
// Panggil checkInPatient untuk validasi dan update status ke "di-loket"
// Gunakan barcode pasien yang fresh, bukan inputValue (untuk memastikan format benar)
const patientBarcodeForCheckIn =
freshPatient.barcode || searchBarcode || inputValue;
console.log(
"🔍 Calling checkInPatient with barcode (fresh):",
patientBarcodeForCheckIn,
);
console.log(
"🔍 Original input:",
inputValue,
"| Extracted barcode:",
searchBarcode,
);
const checkInResult = await queueStore.checkInPatient(
patientBarcodeForCheckIn,
);
if (checkInResult.success && checkInResult.patient) {
// Check-in berhasil
const successKlinikQueueNumber =
checkInResult.patient.noAntrian?.split(" |")[0] ||
checkInResult.patient.noAntrian ||
"N/A";
lastCheckInResult.value = {
success: true,
patientId: checkInResult.patient.barcode,
status: "ALLOWED",
};
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: successKlinikQueueNumber,
pembayaran: checkInResult.patient.pembayaran || "N/A",
status: "success",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "Manual",
klinik: checkInResult.patient.klinik || "N/A",
kodeKlinik: checkInResult.patient.kodeKlinik || null,
});
const successMsg = `✅ Check-in Berhasil!\n\nPasien ${successKlinikQueueNumber} berhasil melakukan check-in dan status berubah menjadi di loket.`;
infoMessage.value = successMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Berhasil",
`Check-in manual berhasil. Tiket ${successKlinikQueueNumber} siap diproses.`,
"success",
"mdi-check-circle",
);
manualInput.value = "";
if (manualForm.value) {
(manualForm.value as any).reset();
}
} else {
// Check-in gagal (misalnya validasi di checkInPatient gagal)
saveToHistory({
patientId: foundPatient.barcode,
queueNumber: foundPatient.noAntrian,
klinikQueueNumber: klinikQueueNumber,
pembayaran: foundPatient.pembayaran || "N/A",
status: "failed",
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: "Manual",
klinik: foundPatient.klinik || "N/A",
kodeKlinik: foundPatient.kodeKlinik || null,
});
lastCheckInResult.value = {
success: false,
patientId: foundPatient.barcode,
status: "FAILED",
};
const errorMsg = `❌ Check-in Gagal!\n\n${checkInResult?.message || "Status pasien tidak memungkinkan untuk check-in."}`;
infoMessage.value = errorMsg;
infoAction.value = "checkin";
infoDialog.value = true;
showSnackbar(
"Gagal",
checkInResult?.message || "Check-in manual gagal dilakukan",
"error",
"mdi-close-circle",
);
}
} catch (error) {
console.error("❌ Fatal Error in checkInManual:", error);
showSnackbar(
"Fatal Error",
"Terjadi kesalahan sistem saat check-in manual",
"error",
"mdi-alert-octagon",
);
}
};
// Quick generate QR for testing
const generateQuickQR = (status: string) => {
// Check and reset daily first (before generating)
checkAndResetDaily();
// Use first klinik if none selected
if (!selectedKlinik.value && klinikOptions.value.length > 0) {
selectedKlinik.value = klinikOptions.value[0].value;
}
generateStatus.value = status;
generateQRCode();
};
// Generate QR Code function - Similar to Anjungan/[id].vue
const generateQRCode = async () => {
if (!selectedKlinik.value) {
showSnackbar("Error", "Mohon pilih Klinik/Poli", "error", "mdi-alert");
return;
}
try {
// Debug: Check if functions are available
console.log("🔍 Checking queueStore functions:", {
generateBarcode: typeof queueStore.generateBarcode,
generateQueueNumber: typeof queueStore.generateQueueNumber,
incrementBarcodeCounter: typeof queueStore.incrementBarcodeCounter,
allPatients: typeof queueStore.allPatients,
});
// Get clinic data
const clinicData = masterStore.klinikList.find(
(k: any) => k.kode === selectedKlinik.value,
);
if (!clinicData) {
showSnackbar("Error", "Klinik tidak ditemukan", "error", "mdi-alert");
return;
}
// Format clinic object untuk generateQueueNumber (same as Anjungan)
const clinic = {
name: clinicData.nama || clinicData.name || clinicData.kode,
kode: clinicData.kode,
};
// Generate barcode using queueStore (same as Anjungan)
// Note: allPatients is a ref, so we pass it directly
if (
!queueStore.generateBarcode ||
typeof queueStore.generateBarcode !== "function"
) {
console.error("❌ generateBarcode is not available in queueStore");
console.error("Available methods:", Object.keys(queueStore));
showSnackbar(
"Error",
"Fungsi generateBarcode tidak tersedia. Silakan refresh halaman atau restart dev server",
"error",
"mdi-alert",
);
return;
}
const barcode = queueStore.generateBarcode([], queueStore.allPatients);
console.log("✅ Generated barcode:", barcode);
// Generate queue number using queueStore (same as Anjungan)
// Format: R/E + Loket (A-N) + 3 digit
const isEksekutif = false; // Default to reguler
if (
!queueStore.generateQueueNumber ||
typeof queueStore.generateQueueNumber !== "function"
) {
console.error("❌ generateQueueNumber is not available in queueStore");
showSnackbar(
"Error",
"Fungsi generateQueueNumber tidak tersedia. Silakan refresh halaman atau restart dev server",
"error",
"mdi-alert",
);
return;
}
const queueNumber = queueStore.generateQueueNumber(
clinic,
selectedPembayaran.value,
isEksekutif,
);
console.log("✅ Generated queue number:", queueNumber);
const noAntrian = `${queueNumber} | Onsite - ${barcode}`;
// Save generated data
generateBarcode.value = barcode;
generateQueueNumber.value = queueNumber;
// QR code hanya berisi barcode saja (sama seperti di thermal print)
generatedQRData.value = barcode;
// Create patient object for printing (similar to Anjungan)
const timestamp = new Date();
generatedPatient.value = {
barcode: barcode,
noAntrian: noAntrian,
klinik:
clinic.name || clinicData.nama || clinicData.name || clinicData.kode,
kodeKlinik: clinic.kode || clinicData.kode,
pembayaran: selectedPembayaran.value,
status: "menunggu", // Status awal seperti di Anjungan
processStage: "loket",
visitType: "SEKARANG",
visitDate: timestamp.toISOString().substring(0, 10),
shift: "Shift 1",
createdAt: timestamp.toISOString(),
registrationType: "onsite",
fastTrack: "TIDAK",
jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String(timestamp.getMinutes()).padStart(2, "0")}`,
};
// Increment barcode counter (same as Anjungan)
queueStore.incrementBarcodeCounter();
await nextTick();
// Clear previous QR code
const qrContainer = document.getElementById("qrcode");
if (qrContainer) {
qrContainer.innerHTML = "";
// Use qrcode package
const QRCode = (await import("qrcode")).default;
// Create QR code as data URL (same settings as useThermalPrint)
const qrDataUrl = await QRCode.toDataURL(generatedQRData.value, {
errorCorrectionLevel: "M",
type: "image/png",
quality: 1,
margin: 2,
width: 400, // Larger preview size
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
// 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);
console.log("Generated patient:", generatedPatient.value);
showSnackbar(
"Berhasil!",
"QR Code berhasil di-generate. Silakan scan untuk testing atau cetak tiket",
"success",
"mdi-check-circle",
);
} else {
showSnackbar(
"Error",
"Container QR Code tidak ditemukan",
"error",
"mdi-alert",
);
}
} catch (error) {
console.error("Error creating QR code:", error);
showSnackbar(
"Error",
"Gagal membuat QR Code. Silakan coba lagi",
"error",
"mdi-alert",
);
}
};
// Handle print ticket (similar to Anjungan/[id].vue)
const handlePrintTicket = async () => {
if (!generatedPatient.value) {
showSnackbar(
"Error",
"Data pasien tidak ditemukan. Silakan generate QR code terlebih dahulu",
"error",
"mdi-alert",
);
return;
}
try {
await printTicketFromPatient(generatedPatient.value);
showSnackbar(
"Berhasil!",
"Tiket berhasil dicetak",
"success",
"mdi-printer",
);
} catch (error) {
console.error("Error printing ticket:", error);
showSnackbar(
"Error",
"Gagal mencetak tiket. Silakan coba lagi",
"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}-${generateQueueNumber.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}-${generateQueueNumber.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}-${generateQueueNumber.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;
klinik?: string;
}) => {
// JANGAN generate nomor antrean baru - selalu gunakan nomor dari pasien yang ditemukan
// Jika queueNumber tidak ada, gunakan klinikQueueNumber atau patientId sebagai fallback
// Tapi jangan generate nomor baru karena itu akan mengubah nomor antrean yang sebenarnya
let queueNumber = item.queueNumber;
if (!queueNumber && item.klinikQueueNumber) {
// Jika queueNumber tidak ada tapi ada klinikQueueNumber, gunakan itu
queueNumber = item.klinikQueueNumber;
} else if (!queueNumber && item.patientId) {
// Jika masih tidak ada, gunakan patientId (barcode) sebagai fallback
queueNumber = item.patientId;
}
// Jangan generate nomor baru - biarkan null/undefined jika memang tidak ada
// Ini memastikan nomor antrean yang ditampilkan sesuai dengan yang ada di tiket
const historyItem = {
...item,
queueNumber:
queueNumber || item.klinikQueueNumber || item.patientId || null,
};
// 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) => {
// Index dari paginatedHistory, perlu dikonversi ke index di checkInHistory
const paginatedItem = paginatedHistory.value[index];
if (!paginatedItem) return;
// Cari index sebenarnya di checkInHistory
const actualIndex = checkInHistory.value.findIndex(
(h) =>
h.patientId === paginatedItem.patientId &&
h.checkInTime === paginatedItem.checkInTime &&
h.method === paginatedItem.method,
);
if (actualIndex !== -1) {
checkInHistory.value.splice(actualIndex, 1);
if (typeof window !== "undefined") {
localStorage.setItem(
HISTORY_STORAGE_KEY,
JSON.stringify(checkInHistory.value),
);
}
showSnackbar(
"Berhasil",
"Riwayat berhasil dihapus",
"success",
"mdi-check",
);
// Reset ke halaman sebelumnya jika halaman saat ini kosong
if (paginatedHistory.value.length === 0 && historyPage.value > 1) {
historyPage.value = Math.max(1, historyPage.value - 1);
}
}
};
const clearHistory = () => {
checkInHistory.value = [];
if (typeof window !== "undefined") {
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
showSnackbar(
"Berhasil",
"Semua riwayat berhasil dihapus",
"success",
"mdi-check",
);
};
const openHistoryDialog = () => {
// Pastikan history dimuat ulang saat dialog dibuka
loadHistory();
// Reset filter dan pagination saat membuka dialog
historySearch.value = "";
historyStatusFilter.value = "";
historyPage.value = 1;
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(() => {
// Pastikan history sudah dimuat
if (checkInHistory.value.length === 0 && typeof window !== "undefined") {
loadHistory();
}
let filtered = [...checkInHistory.value];
// Sort by checkInTime (terbaru di atas)
filtered.sort((a, b) => {
const timeA = new Date(a.checkInTime || a.checkInDate || 0).getTime();
const timeB = new Date(b.checkInTime || b.checkInDate || 0).getTime();
return timeB - timeA; // Descending order (newest first)
});
// 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)) ||
(item.klinikQueueNumber &&
item.klinikQueueNumber.toLowerCase().includes(search)) ||
(item.method && item.method.toLowerCase().includes(search)) ||
(item.klinik && item.klinik.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;
});
// Paginated history
const paginatedHistory = computed(() => {
const start = (historyPage.value - 1) * historyItemsPerPage.value;
const end = start + historyItemsPerPage.value;
return filteredHistory.value.slice(start, end);
});
// Total pages untuk pagination
const totalHistoryPages = computed(() => {
return Math.ceil(filteredHistory.value.length / historyItemsPerPage.value);
});
// Reset page saat filter berubah
watch([historySearch, historyStatusFilter], () => {
historyPage.value = 1;
});
// Recent history untuk ditampilkan di sidebar
// 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;
});
// Sort by checkInTime (terbaru di atas)
return todayHistory.sort((a, b) => {
const timeA = new Date(a.checkInTime || a.checkInDate || 0).getTime();
const timeB = new Date(b.checkInTime || b.checkInDate || 0).getTime();
return timeB - timeA; // Descending order (newest first)
});
});
// Pagination untuk recent history
const recentHistoryPage = ref(1);
const recentHistoryItemsPerPage = ref(10);
// Paginated recent history
const paginatedRecentHistory = computed(() => {
const start = (recentHistoryPage.value - 1) * recentHistoryItemsPerPage.value;
const end = start + recentHistoryItemsPerPage.value;
return recentHistory.value.slice(start, end);
});
// Total pages untuk recent history pagination
const totalRecentHistoryPages = computed(() => {
return Math.ceil(
recentHistory.value.length / recentHistoryItemsPerPage.value,
);
});
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);
// Format jam dengan titik dua (HH:MM:SS)
const timeString = date.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return timeString.replace(/\./g, ":");
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
// Format: HH:MM:SS (menggunakan titik dua)
const timeString = date.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return timeString.replace(/\./g, ":");
};
const getCurrentTime = () => {
// Format jam dengan titik dua (HH:MM:SS)
const timeString = new Date().toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return timeString.replace(/\./g, ":");
};
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];
// Only count items from today (after reset time consideration)
if (itemDateStr !== todayAfterReset) return false;
// Only count successful check-ins
return item.status === "success" || item.status === "ALLOWED";
}).length;
});
// Get today's date after reset (after 2 AM)
const getTodayAfterReset = (): string => {
const now = new Date();
const currentHour = now.getHours();
// If it's before 2 AM, it's still technically "yesterday" operationally
if (currentHour < RESET_HOUR) {
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
return yesterday.toISOString().split("T")[0];
}
// If it's 2 AM or later, it's "today"
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;
});
// Today stats object for sidebar
const todayStats = computed(() => {
const todayAfterReset = getTodayAfterReset();
const todayItems = checkInHistory.value.filter((item) => {
const itemDate = new Date(item.checkInDate || item.checkInTime);
const itemDateStr = itemDate.toISOString().split("T")[0];
return itemDateStr === todayAfterReset;
});
const successItems = todayItems.filter(
(item) => item.status === "success" || item.status === "ALLOWED",
);
const failedItems = todayItems.filter(
(item) =>
item.status === "failed" ||
item.status === "NOT_ALLOWED" ||
item.status === "error",
);
const qrItems = todayItems.filter((item) => item.method === "QR");
const manualItems = todayItems.filter((item) => item.method === "Manual");
return {
total: todayItems.length,
success: successItems.length,
failed: failedItems.length,
qr: qrItems.length,
manual: manualItems.length,
};
});
const statsWaiting = computed(() => {
// Ambil data menunggu dari queueStore untuk stage 'loket'
// Menghitung pasien dengan status 'menunggu' (belum dipanggil) atau 'anjungan' (sudah dipanggil tapi belum check-in)
// Di halaman CheckInPasien, kita menghitung total yang masih menunggu check-in (belum + sudah dipanggil)
try {
const loketPatients = queueStore.getPatientsByStage("loket");
const menungguPatients = loketPatients.value.menunggu || [];
const anjunganPatients = loketPatients.value.anjungan || [];
// Total pasien yang masih menunggu check-in (belum dipanggil + sudah dipanggil tapi belum check-in)
return menungguPatients.length + anjunganPatients.length;
} catch (error) {
console.error("Error calculating statsWaiting:", error);
return 0;
}
});
// Load history on mount
if (typeof window !== "undefined") {
loadHistory();
loadScannedQRHistory();
loadSuccessfulScans();
}
// Calculate sticky top offset based on header height (from AdminLoket.vue pattern)
const stickyTopOffset = ref(100);
const updateStickyOffset = () => {
const header = document.querySelector(".page-header") as HTMLElement;
if (header) {
const headerHeight = header.offsetHeight;
const containerPadding = 16;
stickyTopOffset.value = headerHeight + containerPadding + 8; // header + padding + minimal space
}
};
onMounted(() => {
updateStickyOffset();
window.addEventListener("resize", updateStickyOffset);
// Use nextTick to ensure DOM is fully rendered
setTimeout(updateStickyOffset, 100);
});
onUnmounted(() => {
window.removeEventListener("resize", updateStickyOffset);
});
</script>
<style scoped lang="scss">
@use "@/assets/scss/colors" as *;
/* Page Container - Same pattern as AdminLoket.vue */
.checkin-page-container {
background: $neutral-100;
min-height: 100vh;
max-height: 100vh;
padding: 0 24px;
max-width: 100vw;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Header Full Width - No padding from edges */
.checkin-page-container :deep(.page-header) {
margin-left: -4px;
margin-right: -4px;
border-radius: 0 !important;
width: calc(100% + 8px);
}
.checkin-page-container::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;
}
/* Removed main-card - now using separate cards like AdminLoket.vue */
/* Header Modern Minimalis */
.header-modern {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
padding: 8px 20px 6px;
position: relative;
overflow: hidden;
}
/* Remove rounded edges from PageHeader component */
:deep(.page-header) {
border-radius: 0 !important;
}
/* Replace icon with logo like in AntreanMasuk */
:deep(.header-icon) {
background: rgba(255, 255, 255, 0.95) !important;
border-radius: 50% !important;
width: 60px !important;
height: 60px !important;
padding: 4px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
backdrop-filter: blur(10px) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
overflow: visible !important;
}
:deep(.header-logo) {
width: 52px !important;
height: 52px !important;
object-fit: contain !important;
border-radius: 50% !important;
max-width: 100% !important;
max-height: 100% !important;
}
.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: $neutral-100;
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: $secondary-500 !important;
background: rgba($secondary-500, 0.2);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.tabs-modern :deep(.v-tab--selected .v-icon) {
color: $secondary-500 !important;
}
.tabs-modern :deep(.v-tab--selected span) {
color: $secondary-500 !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: $neutral-100;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.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;
min-height: 100%;
width: 100%;
}
/* Removed old two-column layout - now using AdminLoket.vue pattern */
/* History Section Styling - Modern Design */
.history-section {
border-top: 2px solid rgba($primary-600, 0.1);
padding-top: 24px;
margin-top: 32px;
background: linear-gradient(to bottom, transparent, rgba($primary-50, 0.3));
border-radius: 16px 16px 0 0;
padding: 24px 20px 20px;
margin-left: -20px;
margin-right: -20px;
margin-bottom: -20px;
}
.recent-history-list {
max-height: 650px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
min-height: 0;
}
.recent-history-list::-webkit-scrollbar {
width: 4px;
}
.recent-history-list::-webkit-scrollbar-track {
background: transparent;
}
.recent-history-list::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.history-item-content {
width: 100%;
position: relative;
padding-left: 0;
}
.history-item-compact :deep(.v-card-text) {
padding: 16px 20px !important;
border-left: none !important;
position: relative;
z-index: 0;
}
.history-item-compact {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
background: $neutral-100 !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
border-radius: 12px !important;
border-left: 4px solid transparent !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04) !important;
margin-bottom: 12px !important;
}
.history-item-compact::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
z-index: 1;
border-radius: 8px 0 0 8px;
}
.history-item-compact :deep(.v-card__underlay) {
display: none;
}
.history-item-compact :deep(.v-card__border) {
border: none !important;
}
.history-item-compact:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1) !important;
transform: translateY(-2px);
border-color: rgba(0, 0, 0, 0.12) !important;
}
.history-item-compact.history-success::before {
background: $success-500 !important;
}
.history-item-compact.history-success {
background: rgba($success-100, 0.5) !important;
border-left-color: $success-500 !important;
}
.history-item-compact.history-success :deep(.v-card-text) {
background: transparent !important;
}
.history-item-compact.history-failed::before {
background: $danger-500 !important;
}
.history-item-compact.history-failed {
background: rgba($danger-100, 0.5) !important;
border-left-color: $danger-500 !important;
}
.history-item-compact.history-failed :deep(.v-card-text) {
background: transparent !important;
}
.history-item-compact.history-pending::before {
background: $secondary-500 !important;
}
.history-item-compact.history-pending {
background: rgba($secondary-100, 0.5) !important;
border-left-color: $secondary-500 !important;
}
.history-item-compact.history-pending :deep(.v-card-text) {
background: transparent !important;
}
/* Status Badge */
.history-status-badge {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.history-status-badge.status-success {
background: rgba($success-500, 0.15);
color: $success-600;
}
.history-status-badge.status-error,
.history-status-badge.status-warning {
background: rgba($danger-500, 0.15);
color: $danger-600;
}
.history-status-badge.status-warning {
background: rgba($secondary-500, 0.15);
color: $secondary-600;
}
.history-status-text {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 0;
}
.history-status-text.text-success {
color: $success-600;
}
.history-status-text.text-error {
color: $danger-600;
}
.history-status-text.text-warning {
color: $secondary-600;
}
.history-method-text {
font-size: 10px;
color: $neutral-600;
font-weight: 400;
}
/* Time Badge */
.history-time-badge {
display: flex;
align-items: center;
padding: 2px 6px;
background: $neutral-300;
border-radius: 4px;
font-size: 10px;
color: $neutral-700;
font-weight: 500;
}
/* Klinik Badge */
.history-klinik-badge {
display: flex;
align-items: center;
padding: 2px 6px;
background: rgba($primary-500, 0.1);
color: $primary-600;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
/* Queue Badge */
.history-queue-badge {
display: flex;
align-items: center;
padding: 2px 6px;
background: rgba($secondary-500, 0.1);
color: $secondary-600;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
/* Info Badge (Patient ID & Payment) */
.history-info-badge {
display: flex;
align-items: center;
padding: 2px 6px;
background: $neutral-300;
color: $neutral-700;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
}
.border-top-history {
border-top: 1px solid $neutral-400 !important;
margin-top: 2px;
}
/* Gap utility untuk flex-wrap */
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
/* Removed old responsive layout - now handled by content-grid */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Status Header - Modern Design */
.status-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 14px 16px;
background: linear-gradient(
135deg,
rgba($primary-50, 0.8) 0%,
rgba($primary-100, 0.6) 100%
);
border-radius: 12px;
border: 1px solid rgba($primary-600, 0.12);
box-shadow:
0 4px 12px rgba($primary-600, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
text-align: left;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
min-height: 72px;
max-height: 72px;
flex-shrink: 0;
}
.status-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, $primary-600 0%, $primary-400 100%);
opacity: 0.6;
}
.status-header:hover {
box-shadow:
0 6px 16px rgba($primary-600, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transform: translateY(-1px);
}
.status-icon-wrapper {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
max-width: 40px;
max-height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba($primary-600, 0.25);
flex-shrink: 0;
position: relative;
}
.status-icon-wrapper::after {
content: "";
position: absolute;
inset: 0;
border-radius: 12px;
padding: 1px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.status-icon-wrapper .v-icon {
color: white !important;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.status-text {
flex: 1;
text-align: left;
}
.status-title {
font-size: 15px !important;
font-weight: 700 !important;
color: $neutral-900 !important;
margin: 0 0 2px !important;
letter-spacing: -0.2px !important;
line-height: 1.3 !important;
}
.status-subtitle {
font-size: 12px;
color: $neutral-700;
margin: 0;
line-height: 1.5;
font-weight: 400;
}
/* QR Scanner Modern */
.qr-scanner-container {
display: flex;
justify-content: center;
align-items: center;
margin: 24px 0;
padding: 6px;
}
.qr-placeholder {
width: 100%;
max-width: 320px;
min-height: 200px;
aspect-ratio: 1 / 1;
background: linear-gradient(
135deg,
rgba($primary-50, 0.8) 0%,
rgba($primary-100, 0.6) 100%
);
border-radius: 20px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 3px dashed rgba($primary-600, 0.3);
box-shadow:
0 8px 24px rgba($primary-600, 0.15),
inset 0 2px 8px rgba(255, 255, 255, 0.5);
margin: 0 auto;
transition: all 0.3s ease;
}
.qr-placeholder:hover {
border-color: rgba($primary-600, 0.5);
box-shadow:
0 12px 32px rgba($primary-600, 0.2),
inset 0 2px 8px rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.qr-reader-container {
width: 100%;
max-width: 320px;
margin: 0 auto;
position: relative;
overflow: visible;
}
.qr-reader-wrapper {
width: 100%;
max-width: 320px;
aspect-ratio: 1 / 1;
border-radius: 20px;
overflow: hidden;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.2),
0 4px 16px rgba($primary-600, 0.15);
background: #000;
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid rgba(21, 101, 192, 0.3);
margin: 0 auto;
transition: all 0.3s ease;
}
.qr-reader-wrapper:hover {
box-shadow:
0 16px 48px rgba(0, 0, 0, 0.25),
0 6px 20px rgba($primary-600, 0.2);
border-color: rgba(21, 101, 192, 0.5);
}
.qr-reader-wrapper :deep(video) {
width: 100% !important;
height: 100% !important;
border-radius: 16px;
display: block !important;
object-fit: contain;
background: #000;
aspect-ratio: 1 / 1;
}
.qr-reader-wrapper.is-front :deep(video) {
transform: scaleX(-1); /* Mirror effect only for front camera */
-webkit-transform: scaleX(-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($primary-600, 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: contain;
}
.qr-reader-wrapper.is-front :deep(#qr-reader__scan_region video) {
transform: scaleX(-1); /* Mirror effect only for front camera */
-webkit-transform: scaleX(-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: flex-start;
margin-top: 8px;
padding: 6px 10px;
background: linear-gradient(
135deg,
rgba($primary-600, 0.1) 0%,
rgba($primary-700, 0.1) 100%
);
border-radius: 8px;
color: $primary-600;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
text-align: left;
border: 1px solid rgba($primary-600, 0.2);
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.scanner-instruction .v-icon {
flex-shrink: 0;
margin-right: 8px;
}
.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 $primary-600;
}
.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, $primary-600, transparent);
top: 0;
animation: scan 2s linear infinite;
box-shadow: 0 0 10px $primary-600;
}
@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: $neutral-100 !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
border-radius: 12px !important;
padding: 14px 28px !important;
min-height: 50px !important;
min-width: 44px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 6px 20px rgba($primary-600, 0.35),
0 2px 8px rgba($primary-600, 0.2) !important;
position: relative;
overflow: hidden;
}
.btn-primary-modern::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
.btn-primary-modern:hover::before {
left: 100%;
}
.btn-primary-modern:hover {
transform: translateY(-3px);
box-shadow:
0 10px 28px rgba($primary-600, 0.45),
0 4px 12px rgba($primary-600, 0.25) !important;
}
.btn-primary-modern:active {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba($primary-600, 0.3) !important;
}
.btn-primary-modern:disabled {
opacity: 0.6;
transform: none !important;
cursor: not-allowed;
}
.btn-primary-modern:focus-visible,
.btn-stop-modern:focus-visible {
outline: 3px solid $primary-600;
outline-offset: 3px;
border-radius: 4px;
}
.btn-stop-modern {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: $neutral-100 !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
border-radius: 12px !important;
padding: 14px 28px !important;
min-height: 50px !important;
min-width: 44px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 6px 20px rgba($danger-500, 0.35),
0 2px 8px rgba($danger-500, 0.2) !important;
position: relative;
overflow: hidden;
}
.btn-stop-modern::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
.btn-stop-modern:hover::before {
left: 100%;
}
.btn-stop-modern:hover {
transform: translateY(-3px);
box-shadow:
0 10px 28px rgba($danger-500, 0.45),
0 4px 12px rgba($danger-500, 0.25) !important;
}
.btn-stop-modern:active {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba($danger-500, 0.3) !important;
}
.action-buttons {
margin-top: 24px;
margin-bottom: 8px;
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;
}
/* Focus Indicators for Accessibility */
.v-btn:focus-visible,
.v-text-field:focus-within,
.v-select:focus-within {
outline: 3px solid $primary-600;
outline-offset: 2px;
border-radius: 4px;
}
/* Modern Inputs - Enhanced Design */
.input-modern :deep(.v-field) {
border-radius: 14px;
font-size: 15px;
background: $neutral-100;
border: 2px solid rgba($primary-600, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.input-modern :deep(.v-field:hover) {
border-color: rgba($primary-600, 0.3);
box-shadow: 0 4px 12px rgba($primary-600, 0.08);
}
.input-modern :deep(.v-field--focused) {
background: $neutral-100;
border-color: $primary-600;
box-shadow:
0 0 0 4px rgba(21, 101, 192, 0.12),
0 4px 16px rgba($primary-600, 0.15);
transform: translateY(-1px);
}
.input-modern :deep(.v-field__input) {
padding-top: 12px;
padding-bottom: 12px;
}
.input-modern :deep(.v-label) {
font-weight: 500;
color: $neutral-700;
}
.info-card {
display: flex;
justify-content: center;
margin-top: 20px;
}
.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;
}
.info-alert-centered {
border-radius: 12px !important;
border: 1px solid rgba($primary-600, 0.15) !important;
background: linear-gradient(
135deg,
rgba($primary-50, 0.6),
rgba($primary-100, 0.4)
) !important;
}
.history-header-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(
135deg,
rgba($primary-600, 0.1),
rgba($primary-400, 0.1)
);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba($primary-600, 0.1);
}
.empty-history-icon {
opacity: 0.6;
filter: grayscale(0.3);
}
.empty-history-state {
background: linear-gradient(to bottom, rgba($primary-50, 0.2), transparent);
border-radius: 12px;
padding: 32px 20px !important;
}
.quick-actions {
background: linear-gradient(to bottom, rgba($primary-50, 0.3), transparent);
border-radius: 16px;
padding: 20px;
margin-top: 24px;
}
.quick-actions .v-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 12px !important;
border: 2px solid rgba($primary-600, 0.2) !important;
background: $neutral-100 !important;
font-weight: 500 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04) !important;
}
.quick-actions .v-btn:hover {
transform: translateY(-2px);
border-color: $primary-600 !important;
background: linear-gradient(
135deg,
rgba($primary-50, 0.8),
rgba($primary-100, 0.6)
) !important;
box-shadow: 0 6px 16px rgba($primary-600, 0.2) !important;
color: $primary-700 !important;
}
.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: $neutral-100;
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);
border-radius: 16px !important;
}
@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;
border-radius: 16px 16px 0 0;
}
.dialog-header::before {
display: none;
}
.icon-wrapper {
display: inline-block;
}
.dialog-icon {
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3));
}
.success-header {
background: linear-gradient(135deg, $success-400 0%, $success-600 100%);
}
.warning-header {
background: linear-gradient(135deg, $secondary-400 0%, $secondary-500 100%);
}
.error-header {
background: linear-gradient(135deg, $danger-400 0%, $danger-600 100%);
}
.icon-bg {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.icon-bg-success {
background: linear-gradient(135deg, $success-400 0%, $success-600 100%);
}
.icon-bg-warning {
background: linear-gradient(135deg, $secondary-400 0%, $secondary-500 100%);
}
.icon-bg-error {
background: linear-gradient(135deg, $danger-400 0%, $danger-600 100%);
}
.instruction-box {
border: 1px solid transparent;
}
.instruction-success {
background: rgba($success-100, 0.5) !important;
border-color: rgba($success-500, 0.2) !important;
}
.instruction-warning {
background: rgba($secondary-100, 0.5) !important;
border-color: rgba($secondary-500, 0.2) !important;
}
.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($neutral-300, 0.8) !important;
border-color: rgba(0, 0, 0, 0.08) !important;
}
.patient-info-label {
color: $neutral-600 !important;
font-weight: 500;
opacity: 0.9;
}
.patient-info-value {
color: $neutral-700 !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;
}
/* Content Grid - Same pattern as AdminLoket.vue */
.content-grid {
margin: 0;
flex: 1;
min-height: 0;
overflow: visible;
align-items: stretch;
flex-wrap: nowrap !important;
}
@media (max-width: 1263px) {
.content-grid {
flex-wrap: wrap !important;
}
}
.content-grid > .v-col {
padding: 6px;
}
.content-grid > .v-col:first-child {
padding-left: 6px;
}
.content-grid > .v-col:last-child {
padding-right: 6px;
}
/* Ensure proper column order */
.sticky-column {
position: relative;
}
/* Manual Input Column */
.content-grid > .v-col:nth-child(2) {
}
/* QR Scan Card */
.qr-scan-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba($primary-300, 0.2);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
height: 100%;
}
/* Manual Input Card */
.manual-input-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba($primary-300, 0.2);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
height: 100%;
}
/* Column classes for equal height */
.sticky-column,
.manual-input-column {
display: flex;
flex-direction: column;
}
.sticky-wrapper {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
}
/* Hide right sidebar on all screens */
.stats-sidebar-right {
display: none !important;
}
/* Content Grid */
.content-grid {
flex: 1;
min-height: 0;
overflow: visible;
}
:deep(.stats-sidebar-right) {
overflow: visible !important;
}
@media (min-width: 1264px) {
.stats-sidebar-right {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
align-items: flex-end;
justify-content: flex-end;
flex-shrink: 0 !important;
flex-grow: 0 !important;
width: auto !important;
max-width: 160px !important;
min-width: 140px !important;
z-index: 10;
margin-left: auto;
}
.stats-sidebar-enhanced {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
margin-left: auto;
}
}
@media (min-width: 1264px) {
.content-grid {
flex-wrap: nowrap !important;
}
.stats-sidebar-right {
display: flex !important;
}
}
/* Sticky Wrapper (from AdminLoket.vue pattern) */
.sticky-wrapper {
position: -webkit-sticky; /* Safari */
position: sticky;
top: 16px;
z-index: 10;
align-self: flex-start;
}
@media (min-width: 1264px) {
.sticky-wrapper {
top: 20px;
}
}
/* Compact Alert for Camera Warning */
.compact-alert {
padding: 4px 8px !important;
font-size: 10px !important;
min-height: 48px !important;
max-height: 48px !important;
border-radius: 8px !important;
:deep(.v-alert__prepend) {
margin-right: 4px !important;
}
:deep(.v-icon) {
font-size: 16px !important;
}
:deep(p) {
font-size: 9px !important;
line-height: 1.2 !important;
margin-bottom: 0 !important;
}
:deep(.font-weight-bold) {
font-size: 11px !important;
margin-bottom: 2px !important;
}
:deep(.text-body-2) {
font-size: 9px !important;
}
:deep(.v-alert__content) {
padding: 0 !important;
}
}
@media (max-width: 960px) {
.checkin-page-container {
padding: 0 8px 8px 8px;
max-height: none !important;
overflow-y: auto !important;
}
.checkin-page-container :deep(.page-header) {
margin-left: -8px;
margin-right: -8px;
width: calc(100% + 16px);
}
.content-grid {
flex-direction: column !important;
margin: 0 4px !important;
}
.sticky-column,
.manual-input-column {
width: 100% !important;
max-width: 100% !important;
margin-bottom: 8px;
}
.qr-scan-card,
.manual-input-card {
max-height: 40vh !important;
overflow-y: auto !important;
padding: 12px;
}
/* Alert box mobile styles */
:deep(.v-alert) {
font-size: 12px !important;
padding: 10px 12px !important;
}
:deep(.v-alert .v-alert__prepend) {
margin-right: 8px !important;
}
:deep(.v-alert .v-icon) {
font-size: 20px !important;
}
:deep(.v-alert p) {
font-size: 11px !important;
line-height: 1.4 !important;
}
:deep(.v-alert .font-weight-bold) {
font-size: 13px !important;
}
/* QR Scanner mobile */
.qr-scanner-container {
margin: 12px 0;
}
.qr-placeholder {
max-width: 280px !important;
min-height: 180px !important;
}
/* Buttons mobile */
:deep(.v-btn) {
font-size: 13px !important;
min-height: 44px !important;
}
/* Input fields mobile */
:deep(.v-text-field) {
font-size: 14px !important;
}
:deep(.v-text-field .v-field__input) {
min-height: 44px !important;
padding: 8px 12px !important;
}
.sticky-wrapper {
position: relative;
top: 0;
max-height: none;
}
.stats-footer-horizontal {
flex-wrap: wrap !important;
gap: 6px;
padding: 8px;
}
.stat-item-footer-enhanced {
min-width: calc(50% - 6px);
flex: 1 1 calc(50% - 6px);
padding: 8px 6px;
}
.stat-value-footer-enhanced {
font-size: 13px;
}
.stat-label-footer-enhanced {
font-size: 9px;
}
.stat-icon-footer-enhanced .v-icon {
font-size: 18px !important;
}
}
/* Enhanced Sidebar Design */
.stats-sidebar-enhanced {
display: flex !important;
flex-direction: column;
width: 100%;
max-width: 170px;
min-width: 160px;
height: fit-content;
background: linear-gradient(
180deg,
rgba($primary-50, 0.98) 0%,
rgba($primary-100, 0.92) 100%
);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
box-shadow:
0 4px 20px rgba($primary-600, 0.15),
0 2px 8px rgba($primary-400, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
border: 1px solid rgba($primary-300, 0.4);
overflow: visible;
position: relative;
visibility: visible !important;
opacity: 1 !important;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
90deg,
$primary-600 0%,
$primary-500 50%,
$primary-400 100%
);
opacity: 0.9;
}
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
circle at 50% 10%,
rgba($primary-200, 0.4) 0%,
transparent 60%
);
pointer-events: none;
}
}
/* Sidebar Header */
.sidebar-header {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 6px 6px;
border-bottom: 1px solid rgba($primary-300, 0.3);
position: relative;
z-index: 2;
.sidebar-title {
font-size: 8px;
font-weight: 700;
color: $primary-700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
/* Stats Container */
.stats-container {
display: flex;
flex-direction: column;
gap: 5px;
padding: 8px 6px;
position: relative;
z-index: 2;
}
.stats-sidebar-discord {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 8px;
width: 100%;
max-width: 100px;
height: fit-content;
background: linear-gradient(
180deg,
rgba($primary-50, 0.95) 0%,
rgba($primary-100, 0.85) 100%
);
border-radius: 12px;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
border: 1px solid rgba($primary-600, 0.1);
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, $primary-600 0%, $primary-400 100%);
}
}
@media (min-width: 1264px) {
.stats-sidebar-discord {
position: -webkit-sticky;
position: sticky;
top: 20px;
align-self: flex-start;
}
.stats-sidebar-enhanced {
max-height: calc(100vh - 40px);
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
width: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba($primary-400, 0.3);
border-radius: 10px;
&:hover {
background: rgba($primary-500, 0.5);
}
}
}
}
/* Enhanced Stat Item Design */
.stat-item-enhanced {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
width: 100%;
padding: 8px 5px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba($primary-300, 0.25);
overflow: hidden;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 0;
background: currentColor;
border-radius: 0 3px 3px 0;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba($primary-600, 0.2);
&::before {
height: 40px;
opacity: 1;
}
}
}
.stat-item-enhanced.stat-item-primary {
background: linear-gradient(
135deg,
rgba($primary-100, 0.8) 0%,
rgba($primary-50, 0.6) 100%
);
border-color: rgba($primary-400, 0.3);
&::before {
background: linear-gradient(180deg, $primary-600 0%, $primary-500 100%);
}
&:hover {
background: linear-gradient(
135deg,
rgba($primary-200, 0.9) 0%,
rgba($primary-100, 0.7) 100%
);
border-color: rgba($primary-500, 0.5);
}
}
.stat-item-enhanced.stat-item-success {
background: linear-gradient(
135deg,
rgba($success-100, 0.8) 0%,
rgba($success-200, 0.6) 100%
);
border-color: rgba($success-400, 0.3);
&::before {
background: linear-gradient(180deg, $success-600 0%, $success-500 100%);
}
&:hover {
background: linear-gradient(
135deg,
rgba($success-200, 0.9) 0%,
rgba($success-100, 0.7) 100%
);
border-color: rgba($success-500, 0.5);
}
}
.stat-item-enhanced.stat-item-warning {
background: linear-gradient(
135deg,
rgba($secondary-100, 0.8) 0%,
rgba($secondary-50, 0.6) 100%
);
border-color: rgba($secondary-400, 0.3);
&::before {
background: linear-gradient(180deg, $secondary-600 0%, $secondary-500 100%);
}
&:hover {
background: linear-gradient(
135deg,
rgba($secondary-200, 0.9) 0%,
rgba($secondary-100, 0.7) 100%
);
border-color: rgba($secondary-500, 0.5);
}
}
.stat-item-enhanced.stat-item-error {
background: linear-gradient(
135deg,
rgba($danger-100, 0.8) 0%,
rgba($danger-200, 0.5) 100%
);
border-color: rgba($danger-400, 0.3);
&::before {
background: linear-gradient(180deg, $danger-600 0%, $danger-500 100%);
}
&:hover {
background: linear-gradient(
135deg,
rgba($danger-200, 0.9) 0%,
rgba($danger-100, 0.7) 100%
);
border-color: rgba($danger-500, 0.5);
}
}
.stat-icon-enhanced {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba($primary-300, 0.3);
box-shadow: 0 2px 6px rgba($primary-200, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
margin: 0 auto 8px;
}
.stat-item-enhanced:hover .stat-icon-enhanced {
transform: scale(1.08);
box-shadow: 0 4px 12px rgba($primary-400, 0.4);
}
.stat-content-enhanced {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.stat-value-enhanced {
font-size: 16px;
font-weight: 700;
color: $primary-700;
line-height: 1.2;
margin-bottom: 2px;
}
.stat-item-success .stat-value-enhanced {
color: $success-700;
}
.stat-item-warning .stat-value-enhanced {
color: $secondary-700;
}
.stat-item-error .stat-value-enhanced {
color: $danger-700;
}
.stat-label-enhanced {
font-size: 8px;
color: $neutral-700;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
line-height: 1.2;
}
/* Sidebar Info Section */
.sidebar-info {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px 10px;
position: relative;
z-index: 2;
}
.info-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 6px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba($primary-300, 0.2);
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba($primary-400, 0.4);
transform: translateX(2px);
}
.text-caption {
font-size: 9px;
color: $neutral-700;
font-weight: 500;
}
}
.stat-item-discord {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
width: 100%;
max-width: 84px;
margin: 0 auto;
padding: 8px 4px;
border-radius: 10px;
border: 1px solid transparent;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba($primary-600, 0.15);
}
}
.stat-item-discord::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 0;
background: linear-gradient(180deg, $primary-600 0%, $primary-400 100%);
border-radius: 0 3px 3px 0;
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
}
.stat-item-discord:hover::before {
height: 36px;
opacity: 1;
}
.stat-item-primary {
color: #5865f2;
}
.stat-item-success {
color: #57f287;
}
.stat-item-secondary {
color: #faa61a;
}
.stat-item-discord:hover {
background: rgba(0, 0, 0, 0.04);
transform: translateX(2px);
}
.stat-icon-discord {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba($primary-600, 0.1);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
overflow: hidden;
flex-shrink: 0;
margin: 0 auto;
}
.stat-icon-discord::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
padding: 2px;
background: linear-gradient(135deg, currentColor, transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.25s ease;
}
.stat-item-discord:hover .stat-icon-discord::after {
opacity: 1;
}
.stat-icon-discord .v-icon {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
}
.stat-item-primary .stat-icon-discord {
background: rgba(88, 101, 242, 0.1);
border-color: rgba(88, 101, 242, 0.3);
}
.stat-item-primary:hover .stat-icon-discord {
background: #5865f2;
border-color: #5865f2;
border-radius: 18px;
box-shadow:
0 4px 16px rgba(88, 101, 242, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.stat-item-primary:hover .stat-icon-discord .v-icon {
color: $neutral-100 !important;
transform: scale(1.1) rotate(5deg);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.stat-item-success .stat-icon-discord {
background: rgba(87, 242, 135, 0.1);
border-color: rgba(87, 242, 135, 0.3);
}
.stat-item-success:hover .stat-icon-discord {
background: #57f287;
border-color: #57f287;
border-radius: 18px;
box-shadow:
0 4px 16px rgba(87, 242, 135, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.stat-item-success:hover .stat-icon-discord .v-icon {
color: $neutral-100 !important;
transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.stat-item-secondary .stat-icon-discord {
background: rgba(250, 166, 26, 0.1);
border-color: rgba(250, 166, 26, 0.3);
}
.stat-item-secondary:hover .stat-icon-discord {
background: #faa61a;
border-color: #faa61a;
border-radius: 18px;
box-shadow:
0 4px 16px rgba(250, 166, 26, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.stat-item-secondary:hover .stat-icon-discord .v-icon {
color: $neutral-100 !important;
transform: scale(1.1) rotate(5deg);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.stat-content-discord {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-top: 6px;
opacity: 0.9;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
}
.stat-item-discord:hover .stat-content-discord {
opacity: 1;
transform: translateY(-1px);
}
.stat-value-discord {
font-size: 16px;
font-weight: 700;
color: $neutral-900;
letter-spacing: -0.2px;
line-height: 1.2;
text-align: center;
}
.stat-label-discord {
font-size: 8px;
color: $neutral-700;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
line-height: 1.2;
text-align: center;
margin-top: 1px;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card-modern {
background: $neutral-100;
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, $primary-600 0%, $primary-700 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: $primary-600;
}
.stat-card-modern:hover::before {
transform: scaleX(1);
}
.stat-icon-modern {
margin-bottom: 12px;
display: inline-flex;
padding: 8px;
background: $primary-100;
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: $neutral-700;
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;
overflow-x: hidden;
padding-right: 8px;
min-height: 0;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: $neutral-300;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb {
background: $neutral-600;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: $neutral-700;
}
.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: $success-500;
background: rgba($success-500, 0.05);
}
.history-failed {
border-left-color: $danger-500;
background: rgba($danger-500, 0.05);
}
.history-pending {
border-left-color: $secondary-500;
background: rgba($secondary-500, 0.05);
}
/* History Item Dialog Styling */
.history-item-dialog {
transition: all 0.2s ease;
position: relative;
overflow: hidden;
background: $neutral-100 !important;
border: 1px solid $neutral-400 !important;
border-radius: 10px !important;
border-left: 3px solid transparent !important;
}
.history-item-dialog::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
z-index: 1;
border-radius: 10px 0 0 10px;
}
.history-item-dialog:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
border-color: $neutral-500 !important;
}
.history-item-dialog.history-success::before {
background: $success-500 !important;
}
.history-item-dialog.history-success {
background: rgba($success-100, 0.5) !important;
border-left-color: $success-500 !important;
}
.history-item-dialog.history-failed::before {
background: $danger-500 !important;
}
.history-item-dialog.history-failed {
background: rgba($danger-100, 0.5) !important;
border-left-color: $danger-500 !important;
}
.history-item-dialog.history-pending::before {
background: $secondary-500 !important;
}
.history-item-dialog.history-pending {
background: rgba($secondary-100, 0.5) !important;
border-left-color: $secondary-500 !important;
}
.history-item-dialog.history-item-qr {
border-left-color: $secondary-500 !important;
background: rgba($secondary-100, 0.3) !important;
}
.history-item-dialog.history-item-qr::before {
background: $secondary-500 !important;
}
/* Status Badge Dialog */
.history-status-badge-dialog {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.history-status-badge-dialog.status-success {
background: rgba($success-500, 0.15);
color: $success-600;
}
.history-status-badge-dialog.status-error,
.history-status-badge-dialog.status-warning {
background: rgba($danger-500, 0.15);
color: $danger-600;
}
.history-status-badge-dialog.status-warning {
background: rgba($secondary-500, 0.15);
color: $secondary-600;
}
.history-status-text-dialog {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 0;
}
.history-status-text-dialog.text-success {
color: $success-600;
}
.history-status-text-dialog.text-error {
color: $danger-600;
}
.history-status-text-dialog.text-warning {
color: $secondary-600;
}
.history-method-text-dialog {
font-size: 10px;
color: $neutral-600;
font-weight: 400;
}
/* Queue Badge Dialog */
.history-queue-badge-dialog {
display: flex;
align-items: center;
padding: 3px 8px;
background: rgba($secondary-500, 0.1);
color: $secondary-600;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
/* Info Badge Dialog */
.history-info-badge-dialog {
display: flex;
align-items: center;
padding: 2px 6px;
background: $neutral-300;
color: $neutral-700;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.history-klinik-badge-dialog {
display: flex;
align-items: center;
padding: 2px 6px;
background: rgba($primary-500, 0.1);
color: $primary-600;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.history-payment-badge-dialog {
display: flex;
align-items: center;
padding: 2px 6px;
background: rgba($primary-500, 0.1);
color: $primary-600;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.history-qr-badge-dialog {
display: flex;
align-items: center;
padding: 3px 8px;
background: rgba($primary-500, 0.1);
color: $primary-600;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
/* Time Badge Dialog */
.history-time-badge-dialog {
display: flex;
align-items: center;
padding: 2px 6px;
background: $neutral-300;
border-radius: 4px;
font-size: 10px;
color: $neutral-700;
font-weight: 500;
}
/* Action Buttons */
.history-delete-btn,
.history-action-btn {
opacity: 0.7;
transition: all 0.2s ease;
}
.history-item-dialog:hover .history-delete-btn,
.history-item-dialog:hover .history-action-btn {
opacity: 1;
}
/* History Header Stats */
.history-header-stats {
padding: 12px;
background: $neutral-100;
border-radius: 12px;
border: 1px solid $neutral-400;
}
.history-header-title {
padding-bottom: 6px;
border-bottom: 1px solid $neutral-400;
}
.history-header-title h3 {
color: $neutral-900;
margin: 0;
}
.stat-card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
background: $neutral-100;
border-radius: 8px;
border: 1px solid $neutral-400;
transition: all 0.2s ease;
height: 100%;
}
.stat-card-header:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.stat-icon-header {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-total {
background: rgba($primary-500, 0.15);
color: $primary-600;
}
.stat-icon-success {
background: rgba($success-500, 0.15);
color: $success-600;
}
.stat-icon-failed {
background: rgba($danger-500, 0.15);
color: $danger-600;
}
.stat-icon-filtered {
background: rgba($secondary-500, 0.15);
color: $secondary-600;
}
.stat-content-header {
flex: 1;
min-width: 0;
}
.stat-value-header {
font-size: 16px;
font-weight: 700;
color: $neutral-900;
line-height: 1.2;
margin-bottom: 1px;
}
.stat-label-header {
font-size: 10px;
color: $neutral-600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Mobile Optimization */
@media (max-width: 600px) {
.checkin-page-container {
padding: 0 12px 12px 12px !important;
}
.checkin-page-container :deep(.page-header) {
margin-left: -8px !important;
margin-right: -8px !important;
width: calc(100% + 16px) !important;
}
/* Removed main-card styles */
.header-modern {
padding: 6px 16px 4px !important;
}
.content-modern {
padding: 16px !important;
flex: 1;
min-height: 0;
}
.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: 12px 16px;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
}
.status-icon-wrapper {
margin: 0 auto;
width: 32px;
height: 32px;
}
.status-text {
text-align: center;
width: 100%;
}
.status-title {
font-size: 14px;
}
.status-subtitle {
font-size: 11px;
}
// Sembunyikan elemen sekunder di mobile
.quick-actions {
display: none;
}
.test-camera-section {
display: none;
}
// Pastikan touch target size
.v-btn {
min-height: 44px !important;
min-width: 44px !important;
}
.v-btn--icon {
width: 44px !important;
height: 44px !important;
}
.qr-placeholder {
max-width: 100%;
min-height: 200px;
aspect-ratio: 1 / 1;
}
.qr-reader-container {
max-width: 100%;
overflow: visible;
}
.qr-reader-wrapper {
max-width: 100%;
aspect-ratio: 1 / 1;
border-radius: 16px;
}
.qr-reader-wrapper :deep(video) {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.scanner-instruction {
font-size: 10px;
padding: 6px 8px;
margin-top: 6px;
max-width: 100%;
}
.scanner-instruction .v-icon {
font-size: 14px !important;
margin-right: 6px !important;
}
.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%;
min-height: 180px;
aspect-ratio: 1 / 1;
}
.qr-reader-wrapper {
max-width: 100%;
aspect-ratio: 1 / 1;
}
/* Hide stats sidebar on mobile */
.stats-sidebar-right {
display: none !important;
}
}
/* Additional sidebar positioning fixes */
@media (min-width: 1264px) {
.stats-sidebar-right {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
}
}
/* Desktop - Large screens */
@media (min-width: 1264px) {
.content-grid {
align-items: stretch;
justify-content: center;
flex-wrap: nowrap !important;
}
.qr-scan-card,
.manual-input-card {
max-height: calc(100vh - 200px) !important;
overflow-y: auto;
}
.stats-sidebar-right {
margin-top: 0;
padding-top: 0;
align-self: flex-start;
order: 3 !important;
flex-shrink: 0 !important;
flex-grow: 0 !important;
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.sticky-column {
flex-shrink: 0;
flex-basis: auto;
}
.content-grid > .v-col:nth-child(2) {
flex-shrink: 0;
flex-basis: auto;
}
.stats-sidebar-enhanced,
.stats-sidebar-discord {
margin: 0;
}
}
/* Tablet - Medium screens (961px to 1263px) */
@media (min-width: 961px) and (max-width: 1263px) {
.checkin-page-container {
max-height: none !important;
overflow-y: auto !important;
height: auto !important;
padding-bottom: 20px !important;
}
.qr-scan-card,
.manual-input-card {
max-height: none !important;
overflow-y: visible !important;
height: auto !important;
}
.content-grid {
align-items: stretch;
}
}
/* Small tablets and large phones (721px to 960px) */
@media (min-width: 721px) and (max-width: 960px) {
.checkin-page-container {
max-height: none !important;
overflow-y: auto !important;
height: auto !important;
padding-bottom: 20px !important;
}
.qr-scan-card,
.manual-input-card {
max-height: none !important;
overflow-y: visible !important;
height: auto !important;
}
}
/* Very small screens (up to 720px) */
@media (max-width: 720px) {
.checkin-page-container {
max-height: none !important;
overflow-y: auto !important;
height: auto !important;
padding-bottom: 16px !important;
}
.qr-scan-card,
.manual-input-card {
max-height: none !important;
overflow-y: visible !important;
height: auto !important;
}
.stats-footer-horizontal {
padding: 6px 8px;
gap: 4px;
margin-bottom: 8px !important;
}
}
/* Extra small screens (phones in portrait, up to 480px) */
@media (max-width: 480px) {
.checkin-page-container {
padding: 0 8px 8px 8px !important;
}
.content-grid {
margin: 0 2px !important;
}
.qr-scan-card,
.manual-input-card {
max-height: 38vh !important;
border-radius: 8px;
padding: 8px;
}
/* Alert box extra small */
:deep(.v-alert) {
font-size: 11px !important;
padding: 8px 10px !important;
border-radius: 8px !important;
}
:deep(.v-alert .v-alert__prepend) {
margin-right: 6px !important;
}
:deep(.v-alert .v-icon) {
font-size: 18px !important;
}
:deep(.v-alert p) {
font-size: 10px !important;
}
:deep(.v-alert .font-weight-bold) {
font-size: 12px !important;
}
.stats-footer-horizontal {
padding: 4px 6px;
gap: 3px;
border-radius: 8px;
max-width: calc(100vw - 12px) !important;
}
.stat-item-footer-enhanced {
min-width: calc(50% - 3px);
flex: 1 1 calc(50% - 3px);
padding: 6px 4px;
}
.stat-value-footer-enhanced {
font-size: 11px;
}
.stat-label-footer-enhanced {
font-size: 8px;
}
.stat-icon-footer-enhanced .v-icon {
font-size: 14px !important;
}
}
/* Smooth sticky animation */
@media (min-width: 1264px) {
.stats-sidebar-enhanced {
animation: slideInRight 0.4s ease-out;
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Stats Footer Horizontal - Permanent Footer for All Screens */
.stats-footer-row {
margin-top: 0px !important;
margin-bottom: 2px !important;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.stats-footer-horizontal {
display: flex !important;
width: 100%;
max-width: calc(100vw - 32px);
gap: 5px;
padding: 6px 8px;
background: linear-gradient(
180deg,
rgba($primary-50, 0.98) 0%,
rgba($primary-100, 0.92) 100%
);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid rgba($primary-300, 0.2);
justify-content: center;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
margin: 0 auto;
}
.stat-item-footer-enhanced {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
padding: 5px 4px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.6);
min-width: 65px;
gap: 2px;
}
.stat-item-footer-enhanced:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.9);
}
.stat-icon-footer-enhanced {
margin-bottom: 4px;
}
.stat-content-footer-enhanced {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.stat-value-footer-enhanced {
font-size: 13px;
font-weight: 700;
color: $primary-700;
line-height: 1.2;
margin-bottom: 2px;
}
.stat-item-footer-success .stat-value-footer-enhanced {
color: $success-700;
}
.stat-item-footer-warning .stat-value-footer-enhanced {
color: $secondary-700;
}
.stat-item-footer-error .stat-value-footer-enhanced {
color: $danger-700;
}
.stat-item-footer-info .stat-value-footer-enhanced {
color: $primary-700;
}
.stat-label-footer-enhanced {
font-size: 7px;
color: $neutral-700;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Footer enhanced item colors - match sidebar */
.stat-item-footer-enhanced.stat-item-footer-primary {
background: linear-gradient(
135deg,
rgba($primary-100, 0.8) 0%,
rgba($primary-50, 0.6) 100%
);
border-color: rgba($primary-400, 0.3);
}
.stat-item-footer-enhanced.stat-item-footer-primary:hover {
background: linear-gradient(
135deg,
rgba($primary-200, 0.9) 0%,
rgba($primary-100, 0.7) 100%
);
border-color: rgba($primary-500, 0.5);
}
.stat-item-footer-enhanced.stat-item-footer-success {
background: linear-gradient(
135deg,
rgba($success-100, 0.8) 0%,
rgba($success-200, 0.6) 100%
);
border-color: rgba($success-400, 0.3);
}
.stat-item-footer-enhanced.stat-item-footer-success:hover {
background: linear-gradient(
135deg,
rgba($success-200, 0.9) 0%,
rgba($success-100, 0.7) 100%
);
border-color: rgba($success-500, 0.5);
}
.stat-item-footer-enhanced.stat-item-footer-error {
background: linear-gradient(
135deg,
rgba($danger-100, 0.8) 0%,
rgba($danger-200, 0.5) 100%
);
border-color: rgba($danger-400, 0.3);
}
.stat-item-footer-enhanced.stat-item-footer-error:hover {
background: linear-gradient(
135deg,
rgba($danger-200, 0.9) 0%,
rgba($danger-100, 0.7) 100%
);
border-color: rgba($danger-500, 0.5);
}
.stat-item-footer-enhanced.stat-item-footer-info {
background: linear-gradient(
135deg,
rgba($primary-100, 0.8) 0%,
rgba($primary-50, 0.6) 100%
);
border-color: rgba($primary-400, 0.3);
}
.stat-item-footer-enhanced.stat-item-footer-info:hover {
background: linear-gradient(
135deg,
rgba($primary-200, 0.9) 0%,
rgba($primary-100, 0.7) 100%
);
border-color: rgba($primary-500, 0.5);
}
.stat-item-footer-enhanced.stat-item-footer-warning {
background: linear-gradient(
135deg,
rgba($secondary-100, 0.8) 0%,
rgba($secondary-50, 0.6) 100%
);
border-color: rgba($secondary-400, 0.3);
}
.stat-item-footer-enhanced.stat-item-footer-warning:hover {
background: linear-gradient(
135deg,
rgba($secondary-200, 0.9) 0%,
rgba($secondary-100, 0.7) 100%
);
border-color: rgba($secondary-500, 0.5);
}
/* Mobile Landscape */
@media (max-width: 900px) and (orientation: landscape) {
.qr-reader-wrapper {
max-width: 100%;
aspect-ratio: 1 / 1;
}
.qr-reader-wrapper :deep(video) {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
.qr-code-container {
background: white;
padding: 12px;
border-radius: 12px;
display: inline-block;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.qr-code-container img {
display: block;
margin: 0 auto;
}
/* Padding Consistency Overrides for Tablet/Intermediate */
@media (min-width: 601px) and (max-width: 1263px) {
.checkin-page-container {
padding: 0 16px !important;
}
}
</style>