6550 lines
204 KiB
Vue
6550 lines
204 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>
|
|
</div>
|
|
<div id="qr-reader" class="qr-reader-wrapper" role="region" aria-label="Area pemindaian QR code" aria-live="polite" :aria-busy="cameraChecking">
|
|
<div class="scanner-loading-overlay" v-if="!cameraReady">
|
|
<v-progress-circular
|
|
indeterminate
|
|
color="white"
|
|
size="48"
|
|
></v-progress-circular>
|
|
<p class="text-white mt-4">Memuat kamera...</p>
|
|
</div>
|
|
</div>
|
|
<div class="scanner-instruction">
|
|
<v-icon :color="primaryColor" size="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 { 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();
|
|
|
|
|
|
// 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);
|
|
let html5QrCode: any = null;
|
|
const qrCodeId = 'qr-reader';
|
|
let lastScannedQR: string | null = null;
|
|
let lastScanTime: number = 0;
|
|
const SCAN_DEBOUNCE_MS = 2000; // 2 detik debounce untuk mencegah scan berulang
|
|
|
|
// Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian)
|
|
const SUCCESSFUL_SCANS_KEY = 'successful_qr_scans';
|
|
const successfulScans = ref<Set<string>>(new Set());
|
|
|
|
// Detect mobile device
|
|
const isMobile = ref(false);
|
|
if (typeof window !== 'undefined') {
|
|
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
|
(window.innerWidth <= 768);
|
|
}
|
|
|
|
// History Dialog
|
|
const historyDialog = ref(false);
|
|
const historySearch = ref('');
|
|
const historyStatusFilter = ref('');
|
|
const 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' }
|
|
];
|
|
|
|
// 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 untuk mobile dan desktop
|
|
const cameraConfig = isMobile.value
|
|
? { facingMode: "environment" } // Mobile: gunakan kamera belakang
|
|
: { facingMode: "user" }; // Desktop: gunakan kamera depan (webcam)
|
|
|
|
// Video constraints yang dioptimalkan untuk kualitas tinggi
|
|
const baseVideoConstraints = isMobile.value
|
|
? {
|
|
facingMode: "environment",
|
|
width: { ideal: 1280, min: 640, max: 1920 },
|
|
height: { ideal: 720, min: 480, max: 1080 }
|
|
}
|
|
: {
|
|
facingMode: "user",
|
|
width: { ideal: 1920, min: 1280, max: 1920 },
|
|
height: { ideal: 1080, min: 720, max: 1080 }
|
|
};
|
|
|
|
// Tambahkan advanced constraints hanya jika didukung
|
|
const videoConstraints: any = { ...baseVideoConstraints };
|
|
|
|
console.log('Starting QR scanner with config:', {
|
|
isMobile: isMobile.value,
|
|
cameraConfig,
|
|
videoConstraints
|
|
});
|
|
|
|
try {
|
|
await html5QrCode.start(
|
|
cameraConfig,
|
|
{
|
|
fps: 15, // Reduced FPS improved stability on mobile
|
|
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
|
|
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
|
|
// Use 70% of the viewfinder for the qrbox to ensure it's comfortably inside
|
|
const qrboxSize = Math.floor(minEdgeSize * 0.7);
|
|
return {
|
|
width: Math.max(qrboxSize, 200),
|
|
height: Math.max(qrboxSize, 200)
|
|
};
|
|
},
|
|
aspectRatio: 1.0,
|
|
disableFlip: false,
|
|
videoConstraints: videoConstraints,
|
|
rememberLastUsedCamera: true,
|
|
showTorchButtonIfSupported: true,
|
|
// Tambahkan opsi untuk meningkatkan deteksi
|
|
verbose: false // Set true untuk debugging
|
|
},
|
|
(decodedText: string, decodedResult: any) => {
|
|
// QR Code berhasil di-scan
|
|
console.log('✅ QR Code detected:', decodedText);
|
|
console.log('📊 Decoded result:', decodedResult);
|
|
|
|
// Validasi dan proses QR code
|
|
if (decodedText && decodedText.trim() !== '') {
|
|
handleQRScanSuccess(decodedText);
|
|
} else {
|
|
console.warn('Empty QR code detected');
|
|
}
|
|
},
|
|
(errorMessage: string) => {
|
|
// Log error untuk debugging - hanya log error penting
|
|
// Error ini biasanya muncul terus menerus saat tidak ada QR code yang terdeteksi
|
|
// Jadi kita filter untuk menghindari spam console
|
|
if (errorMessage) {
|
|
// Filter out common non-critical errors
|
|
const isCriticalError = !errorMessage.includes('NotFoundException') &&
|
|
!errorMessage.includes('No QR') &&
|
|
!errorMessage.includes('QR code parse error') &&
|
|
!errorMessage.includes('QR code parse error, error') &&
|
|
!errorMessage.includes('QR code decode error');
|
|
|
|
if (isCriticalError) {
|
|
console.warn('QR Scanner warning:', errorMessage);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set camera ready setelah sedikit delay untuk memastikan video sudah dimuat
|
|
setTimeout(() => {
|
|
cameraReady.value = true;
|
|
console.log('Camera ready, scanner is active');
|
|
}, 500);
|
|
|
|
showSnackbar('Info', 'Scanner aktif. Arahkan kamera ke QR code dan pastikan pencahayaan cukup', 'info', 'mdi-camera');
|
|
} catch (cameraError: any) {
|
|
console.error('Camera error:', cameraError);
|
|
|
|
// Jika kamera belakang tidak tersedia di mobile, coba kamera depan
|
|
if (isMobile.value && cameraError.name === 'NotFoundError') {
|
|
try {
|
|
console.log('Trying front camera as fallback...');
|
|
await html5QrCode.start(
|
|
{ facingMode: "user" },
|
|
{
|
|
fps: 30,
|
|
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
|
|
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
|
|
const percentageSize = Math.floor(minEdgeSize * 0.85);
|
|
const fixedSize = 300;
|
|
const qrboxSize = Math.max(percentageSize, fixedSize);
|
|
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.95);
|
|
|
|
return {
|
|
width: Math.max(finalSize, 250),
|
|
height: Math.max(finalSize, 250)
|
|
};
|
|
},
|
|
aspectRatio: 1.0,
|
|
disableFlip: false,
|
|
videoConstraints: {
|
|
facingMode: "user",
|
|
width: { ideal: 1280, min: 640, max: 1920 },
|
|
height: { ideal: 720, min: 480, max: 1080 }
|
|
},
|
|
rememberLastUsedCamera: true,
|
|
verbose: false
|
|
},
|
|
(decodedText: string, decodedResult: any) => {
|
|
console.log('✅ QR Code detected (front camera):', decodedText);
|
|
if (decodedText && decodedText.trim() !== '') {
|
|
handleQRScanSuccess(decodedText);
|
|
}
|
|
},
|
|
(errorMessage: string) => {
|
|
if (errorMessage) {
|
|
const isCriticalError = !errorMessage.includes('NotFoundException') &&
|
|
!errorMessage.includes('No QR') &&
|
|
!errorMessage.includes('QR code parse error') &&
|
|
!errorMessage.includes('QR code decode error');
|
|
|
|
if (isCriticalError) {
|
|
console.warn('QR Scanner (front) warning:', errorMessage);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
setTimeout(() => {
|
|
cameraReady.value = true;
|
|
}, 500);
|
|
|
|
showSnackbar('Info', 'Menggunakan kamera depan. Arahkan QR code ke kamera', 'info', 'mdi-camera');
|
|
} catch (fallbackError: any) {
|
|
console.error('Fallback camera also failed:', fallbackError);
|
|
throw cameraError; // Throw original error
|
|
}
|
|
} else {
|
|
// Coba menggunakan deviceId langsung jika facingMode gagal
|
|
try {
|
|
console.log('Trying to get camera devices...');
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
|
|
|
if (videoDevices.length > 0) {
|
|
// Gunakan kamera pertama yang tersedia
|
|
const deviceId = videoDevices[0].deviceId;
|
|
console.log('Using device:', deviceId, videoDevices[0].label);
|
|
|
|
await html5QrCode.start(
|
|
{ deviceId: { exact: deviceId } },
|
|
{
|
|
fps: 30,
|
|
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
|
|
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
|
|
const percentageSize = Math.floor(minEdgeSize * 0.80);
|
|
const fixedSize = isMobile.value ? 300 : 400;
|
|
const qrboxSize = Math.max(percentageSize, fixedSize);
|
|
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80);
|
|
|
|
return {
|
|
width: Math.max(finalSize, 250),
|
|
height: Math.max(finalSize, 250)
|
|
};
|
|
},
|
|
aspectRatio: 1.0,
|
|
disableFlip: false,
|
|
videoConstraints: {
|
|
deviceId: { exact: deviceId },
|
|
width: { ideal: isMobile.value ? 1280 : 1920, min: isMobile.value ? 640 : 1280, max: isMobile.value ? 1920 : 1920 },
|
|
height: { ideal: isMobile.value ? 720 : 1080, min: isMobile.value ? 480 : 720, max: isMobile.value ? 1080 : 1080 }
|
|
},
|
|
rememberLastUsedCamera: true,
|
|
verbose: false
|
|
},
|
|
(decodedText: string, decodedResult: any) => {
|
|
console.log('✅ QR Code detected (deviceId):', decodedText);
|
|
if (decodedText && decodedText.trim() !== '') {
|
|
handleQRScanSuccess(decodedText);
|
|
}
|
|
},
|
|
(errorMessage: string) => {
|
|
if (errorMessage) {
|
|
const isCriticalError = !errorMessage.includes('NotFoundException') &&
|
|
!errorMessage.includes('No QR') &&
|
|
!errorMessage.includes('QR code parse error') &&
|
|
!errorMessage.includes('QR code decode error');
|
|
|
|
if (isCriticalError) {
|
|
console.warn('QR Scanner (deviceId) warning:', errorMessage);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
setTimeout(() => {
|
|
cameraReady.value = true;
|
|
}, 500);
|
|
|
|
showSnackbar('Info', 'Scanner aktif menggunakan kamera yang tersedia', 'info', 'mdi-camera');
|
|
} else {
|
|
throw cameraError;
|
|
}
|
|
} catch (deviceError: any) {
|
|
console.error('DeviceId approach also failed:', deviceError);
|
|
throw cameraError;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error starting scanner:', err);
|
|
isScanning.value = false;
|
|
cameraReady.value = false;
|
|
|
|
// Don't set hasCamera to false if it was detected, just log the error
|
|
let errorMessage = 'Gagal memulai scanner. ';
|
|
|
|
if (err.message && err.message.includes('not found')) {
|
|
errorMessage = 'Element scanner tidak ditemukan. Silakan refresh halaman dan coba lagi.';
|
|
console.error('Element not found error. Element ID:', qrCodeId);
|
|
} else if (err.name === 'NotAllowedError') {
|
|
errorMessage = 'Akses kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.';
|
|
} else if (err.name === 'NotFoundError') {
|
|
errorMessage = 'Kamera tidak ditemukan. Silakan gunakan input manual.';
|
|
hasCamera.value = false;
|
|
} else if (err.name === 'NotReadableError') {
|
|
errorMessage = 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi lain yang menggunakan kamera.';
|
|
} else {
|
|
errorMessage += err.message || err.name || 'Unknown error';
|
|
}
|
|
|
|
showSnackbar('Error', errorMessage, 'error', 'mdi-alert');
|
|
}
|
|
};
|
|
|
|
const stopScanning = async () => {
|
|
try {
|
|
if (html5QrCode) {
|
|
// Stop scanner
|
|
if (isScanning.value) {
|
|
await html5QrCode.stop();
|
|
await html5QrCode.clear();
|
|
}
|
|
|
|
// Note: Media tracks are automatically stopped when html5QrCode.stop() is called
|
|
|
|
isScanning.value = false;
|
|
cameraReady.value = false;
|
|
showSnackbar('Info', 'Scanner dihentikan', 'info', 'mdi-camera-off');
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error stopping scanner:', err);
|
|
isScanning.value = false;
|
|
cameraReady.value = false;
|
|
// Force cleanup
|
|
html5QrCode = null;
|
|
}
|
|
};
|
|
|
|
const handleQRScanSuccess = (decodedText: string) => {
|
|
console.log('🎯 QR Scan Success! Data:', decodedText);
|
|
|
|
// Validasi data QR
|
|
if (!decodedText || decodedText.trim() === '') {
|
|
console.warn('QR Code data is empty');
|
|
showSnackbar('Error', 'QR Code tidak valid atau kosong', 'error', 'mdi-alert');
|
|
return;
|
|
}
|
|
|
|
// Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian)
|
|
// 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.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');
|
|
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
|
|
const now = Date.now();
|
|
if (lastScannedQR === decodedText && (now - lastScanTime) < SCAN_DEBOUNCE_MS) {
|
|
console.log('⏭️ Scan diabaikan (debounce): QR code sama dalam waktu singkat');
|
|
return;
|
|
}
|
|
|
|
// Update last scan info
|
|
lastScannedQR = decodedText;
|
|
lastScanTime = now;
|
|
|
|
// Simpan data QR yang di-scan
|
|
scannedData.value = decodedText;
|
|
|
|
// Scanner tetap berjalan untuk memungkinkan scan QR code berikutnya
|
|
|
|
// Proses data QR
|
|
try {
|
|
// onDetect is async, so we need to handle it properly
|
|
onDetect(decodedText).catch((error) => {
|
|
console.error('Error processing QR data in onDetect:', error);
|
|
// Jangan simpan sebagai successful scan jika ada error
|
|
// 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,
|
|
klinikQueueNumber: null,
|
|
pembayaran: 'N/A',
|
|
status: 'failed',
|
|
checkInTime: new Date().toISOString(),
|
|
checkInDate: new Date().toISOString(),
|
|
method: 'QR Scan',
|
|
klinik: 'N/A',
|
|
kodeKlinik: null
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('Error processing QR data:', error);
|
|
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,
|
|
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;
|
|
}
|
|
|
|
// Load history untuk ditampilkan di sidebar
|
|
loadHistory();
|
|
|
|
// Check and reset daily at 10 PM
|
|
checkAndResetDaily();
|
|
|
|
// Set interval to check every minute for reset time
|
|
setInterval(() => {
|
|
checkAndResetDaily();
|
|
}, 60000); // Check every minute
|
|
} else {
|
|
hasCamera.value = false;
|
|
cameraChecking.value = false;
|
|
}
|
|
});
|
|
|
|
// Cleanup saat component unmount
|
|
onUnmounted(async () => {
|
|
// Stop scanning jika aktif
|
|
if (html5QrCode) {
|
|
try {
|
|
if (isScanning.value) {
|
|
await html5QrCode.stop();
|
|
await html5QrCode.clear();
|
|
}
|
|
// Destroy instance
|
|
html5QrCode = null;
|
|
} catch (err) {
|
|
console.warn('Error stopping scanner on unmount:', err);
|
|
html5QrCode = null;
|
|
}
|
|
}
|
|
|
|
// Note: Media tracks are automatically stopped when html5QrCode.stop() is called
|
|
// No need to manually stop tracks here
|
|
|
|
// Clear auto-close timer jika ada
|
|
if (autoCloseTimer) {
|
|
clearTimeout(autoCloseTimer);
|
|
autoCloseTimer = null;
|
|
}
|
|
|
|
// Reset state
|
|
isScanning.value = false;
|
|
cameraReady.value = false;
|
|
});
|
|
|
|
// 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;
|
|
};
|
|
|
|
const onDetect = async (decodedText: string) => {
|
|
// 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.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.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;
|
|
|
|
// Scanner tetap berjalan setelah dialog ditutup
|
|
// Ini memungkinkan user untuk segera memproses QR code berikutnya
|
|
// tanpa perlu memulai scanner lagi
|
|
};
|
|
|
|
// Watch infoDialog untuk auto-close setelah 3 detik
|
|
watch(infoDialog, (isOpen: boolean) => {
|
|
// Clear timer yang ada jika dialog ditutup
|
|
if (autoCloseTimer) {
|
|
clearTimeout(autoCloseTimer);
|
|
autoCloseTimer = null;
|
|
}
|
|
|
|
// Jika dialog dibuka, set timer untuk auto-close setelah 3 detik
|
|
if (isOpen) {
|
|
autoCloseTimer = setTimeout(() => {
|
|
infoDialog.value = false;
|
|
autoCloseTimer = null;
|
|
}, 5000); // 5 detik
|
|
}
|
|
});
|
|
|
|
const showSnackbar = (title: string, message: string, color: string, icon: string) => {
|
|
snackbar.value.title = title;
|
|
snackbar.value.message = message;
|
|
snackbar.value.color = color;
|
|
snackbar.value.icon = icon;
|
|
snackbar.value.show = true;
|
|
};
|
|
|
|
const checkInManual = async () => {
|
|
if (!manualInput.value) {
|
|
showSnackbar('Error', 'Mohon isi nomor antrean, barcode, atau ID pasien', 'error', 'mdi-alert');
|
|
return;
|
|
}
|
|
|
|
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);
|
|
|
|
// Cari pasien dengan dua metode:
|
|
// 1. EXACT barcode match (prioritas tertinggi) - format: YYMMDD + 5 digit (contoh: 26011500001)
|
|
// 2. EXACT nomor antrean match - format: RA020, F-RA001, EA013, dll
|
|
// Menggunakan matchNoAntrian helper untuk memastikan kode + angka match dengan benar
|
|
const foundPatient = queueStore.allPatients.find(p => {
|
|
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
|
|
// Ini mencegah false positive seperti RA020 terdeteksi sebagai RA002
|
|
if (p.noAntrian && matchNoAntrian(originalInput, p.noAntrian)) {
|
|
console.log('✅ Found by exact nomor antrean match:', p.noAntrian, '===', originalInput);
|
|
return true;
|
|
}
|
|
|
|
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
|
|
});
|
|
|
|
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)
|
|
const freshPatient = queueStore.allPatients.find(p => {
|
|
if (!p) return false;
|
|
|
|
// Match by barcode
|
|
const patientBarcode = String(p.barcode || '').trim();
|
|
if (patientBarcode === foundPatient.barcode ||
|
|
patientBarcode === searchBarcode ||
|
|
patientBarcode === cleanInput) {
|
|
return true;
|
|
}
|
|
|
|
// Match by nomor antrean (jika input adalah nomor antrean)
|
|
if (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
|
|
});
|
|
|
|
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');
|
|
}
|
|
};
|
|
|
|
// 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: cover;
|
|
background: #000;
|
|
aspect-ratio: 1 / 1;
|
|
transform: scaleX(-1); /* Mirror effect */
|
|
-webkit-transform: scaleX(-1); /* Safari support */
|
|
/* Reduce brightness to prevent overexposure/bloom */
|
|
filter: brightness(0.75) contrast(1.1);
|
|
-webkit-filter: brightness(0.75) contrast(1.1);
|
|
}
|
|
|
|
.qr-reader-wrapper :deep(canvas) {
|
|
display: none !important;
|
|
}
|
|
|
|
.qr-reader-wrapper :deep(#qr-reader__dashboard) {
|
|
display: none !important;
|
|
}
|
|
|
|
.qr-reader-wrapper :deep(#qr-reader__scan_region) {
|
|
border-radius: 16px;
|
|
border: 3px solid rgba($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: cover;
|
|
transform: scaleX(-1); /* Mirror effect */
|
|
-webkit-transform: scaleX(-1); /* Safari support */
|
|
/* Reduce brightness to prevent overexposure/bloom */
|
|
filter: brightness(0.75) contrast(1.1);
|
|
-webkit-filter: brightness(0.75) contrast(1.1);
|
|
}
|
|
|
|
.scanner-status {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 6px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.scanner-instruction {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: 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> |