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