QRscan implementation
This commit is contained in:
No files matched your search
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="500"
|
||||
persistent
|
||||
transition="dialog-transition"
|
||||
scrim="rgba(0, 0, 0, 0.5)"
|
||||
class="blur-dialog"
|
||||
>
|
||||
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||
<div class="dialog-header text-center pa-8" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'success-header' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'warning-header' : 'error-header'">
|
||||
<div class="decorative-circles">
|
||||
<div class="circle circle-1"></div>
|
||||
<div class="circle circle-2"></div>
|
||||
<div class="circle circle-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="icon-wrapper mb-4">
|
||||
<div class="icon-bg" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'icon-bg-success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'icon-bg-warning' : 'icon-bg-error'">
|
||||
<v-icon size="64" color="white" class="dialog-icon">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-alert-circle' : 'mdi-close-circle' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-h4 font-weight-bold text-white mb-2">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin'
|
||||
? 'Check-in Berhasil!'
|
||||
: lastCheckInResult?.status === 'NOT_ALLOWED'
|
||||
? 'Belum Diizinkan'
|
||||
: 'Check-in Gagal' }}
|
||||
</h2>
|
||||
|
||||
<div class="status-badge mt-3">
|
||||
<v-chip
|
||||
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'white' : 'white'"
|
||||
:text-color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'"
|
||||
size="small"
|
||||
class="font-weight-bold"
|
||||
>
|
||||
<v-icon start size="16">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
|
||||
</v-icon>
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin'
|
||||
? 'Berhasil'
|
||||
: lastCheckInResult?.status === 'NOT_ALLOWED'
|
||||
? 'Menunggu'
|
||||
: 'Gagal' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-8">
|
||||
<div class="message-container">
|
||||
<div class="message-icon mb-4">
|
||||
<v-icon :color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'" size="32">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<div class="text-h6 font-weight-bold mb-3 text-center" style="white-space: pre-line;">{{ infoMessage }}</div>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<div v-if="lastCheckInResult" class="instruction-box pa-4 rounded-lg" :class="lastCheckInResult.success && infoAction === 'checkin' ? 'instruction-success' : 'instruction-warning'">
|
||||
<div class="d-flex align-start">
|
||||
<v-icon :color="lastCheckInResult.success && infoAction === 'checkin' ? 'success' : 'orange'" class="mr-3 mt-1">
|
||||
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'mdi-check-circle' : 'mdi-timer-sand' }}
|
||||
</v-icon>
|
||||
<div>
|
||||
<p class="text-body-1 font-weight-medium mb-1">
|
||||
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'Status Check-in:' : 'Status:' }}
|
||||
</p>
|
||||
<p class="text-body-2 text-grey-darken-1">
|
||||
{{ lastCheckInResult.success && infoAction === 'checkin'
|
||||
? 'Check-in telah berhasil dilakukan. Pasien dapat melanjutkan ke tahap selanjutnya.'
|
||||
: lastCheckInResult.status === 'NOT_ALLOWED'
|
||||
? 'Mohon menunggu hingga antrean Anda dipanggil oleh petugas'
|
||||
: 'Proses check-in gagal. Silakan coba lagi atau hubungi petugas.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Info Card -->
|
||||
<v-card variant="outlined" class="mt-4 patient-info-card" color="grey-lighten-4">
|
||||
<v-card-text class="py-3">
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="mr-2 patient-info-icon">mdi-account-circle</v-icon>
|
||||
<div>
|
||||
<p class="text-caption patient-info-label mb-0">ID Pasien</p>
|
||||
<p class="text-body-1 patient-info-value mb-0">{{ scannedData?.split('|')[0] || 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-caption patient-info-label mb-0">Waktu Scan</p>
|
||||
<p class="text-body-2 patient-info-value mb-0">{{ new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-row dense>
|
||||
<v-col v-if="infoAction === 'kembali'" cols="12">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
class="text-white font-weight-bold text-none dialog-button"
|
||||
size="x-large"
|
||||
block
|
||||
variant="flat"
|
||||
@click="handleClose"
|
||||
elevation="0"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
>
|
||||
Kembali
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<template v-else>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : 'primary'"
|
||||
class="text-white font-weight-bold text-none dialog-button"
|
||||
size="x-large"
|
||||
block
|
||||
variant="flat"
|
||||
@click="handleClose"
|
||||
elevation="4"
|
||||
:prepend-icon="lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : 'mdi-close'"
|
||||
>
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'Tutup' : 'Tutup' }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CheckInResult, InfoAction } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
lastCheckInResult: CheckInResult | null
|
||||
infoMessage: string
|
||||
infoAction: InfoAction
|
||||
scannedData: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'close': []
|
||||
}>()
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="header-modern">
|
||||
<div class="header-content">
|
||||
<div class="icon-circle">
|
||||
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="title-modern">Check-in Pasien</h1>
|
||||
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Minimalis -->
|
||||
<v-tabs
|
||||
:model-value="modelValue"
|
||||
@update:model-value="updateTab"
|
||||
align-tabs="center"
|
||||
class="tabs-modern mt-3"
|
||||
bg-color="transparent"
|
||||
slider-color="#FB8C00"
|
||||
height="40"
|
||||
>
|
||||
<v-tab value="scan" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||
<span>Scan QR</span>
|
||||
</v-tab>
|
||||
<v-tab value="manual" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
|
||||
<span>Manual</span>
|
||||
</v-tab>
|
||||
<v-tab value="generate" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
|
||||
<span>Generate QR</span>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabValue } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: TabValue
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: TabValue): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const updateTab = (value: TabValue) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Header Modern Minimalis */
|
||||
.header-modern {
|
||||
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
|
||||
padding: 20px 24px 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-modern::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
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 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-modern {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle-modern {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 2px 0 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tabs-modern {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-tab) {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
min-width: 120px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-tab:hover) {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-tab--selected) {
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-slider) {
|
||||
background-color: #FB8C00 !important;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import QRCode from 'qrcode';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
patientId: string;
|
||||
status: string;
|
||||
generatedQRData: string | null;
|
||||
statusOptions: Array<{ title: string; value: string }>;
|
||||
primaryColor: string;
|
||||
generateRandomPatientId: () => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:patientId': [value: string];
|
||||
'update:status': [value: string];
|
||||
'generate': [];
|
||||
'quick-generate': [patientId: string, status: string];
|
||||
'download': [];
|
||||
'copy': [];
|
||||
'share': [];
|
||||
'generate-random-id': [];
|
||||
}>();
|
||||
|
||||
const localPatientId = computed({
|
||||
get: () => props.patientId,
|
||||
set: (value) => emit('update:patientId', value),
|
||||
});
|
||||
|
||||
const localStatus = computed({
|
||||
get: () => props.status,
|
||||
set: (value) => emit('update:status', value),
|
||||
});
|
||||
|
||||
const handleGenerate = () => {
|
||||
emit('generate');
|
||||
};
|
||||
|
||||
const handleQuickGenerate = (status: string) => {
|
||||
const randomId = props.generateRandomPatientId();
|
||||
emit('quick-generate', randomId, status);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
emit('download');
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
emit('copy');
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
emit('share');
|
||||
};
|
||||
|
||||
const handleGenerateRandomId = () => {
|
||||
emit('generate-random-id');
|
||||
};
|
||||
|
||||
const qrContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Function to render QR code
|
||||
const renderQRCode = async (data: string) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready
|
||||
await nextTick();
|
||||
|
||||
// Wait a bit more to ensure ref is mounted (especially when switching tabs)
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
if (!qrContainerRef.value) {
|
||||
console.warn('QR container ref not available yet, retrying...');
|
||||
// Retry after a short delay
|
||||
setTimeout(() => {
|
||||
if (qrContainerRef.value && data) {
|
||||
renderQRCode(data);
|
||||
}
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear previous content
|
||||
qrContainerRef.value.innerHTML = '';
|
||||
|
||||
// Generate QR code
|
||||
const qrDataUrl = await QRCode.toDataURL(data, {
|
||||
errorCorrectionLevel: 'M',
|
||||
type: 'image/png',
|
||||
quality: 0.92,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
width: 300,
|
||||
});
|
||||
|
||||
// Create and append image
|
||||
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';
|
||||
qrContainerRef.value.appendChild(img);
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for generatedQRData changes to render QR code
|
||||
watch(() => props.generatedQRData, async (newData) => {
|
||||
if (newData) {
|
||||
// Use setTimeout to ensure DOM is ready, especially when switching tabs
|
||||
setTimeout(() => {
|
||||
renderQRCode(newData);
|
||||
}, 200);
|
||||
} else if (qrContainerRef.value) {
|
||||
// Clear container if data is cleared
|
||||
qrContainerRef.value.innerHTML = '';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Also watch for when component is mounted and ref is ready
|
||||
onMounted(() => {
|
||||
if (props.generatedQRData) {
|
||||
// Delay to ensure ref is mounted
|
||||
setTimeout(() => {
|
||||
renderQRCode(props.generatedQRData!);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">Generate QR Code untuk Testing</h3>
|
||||
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Preset Buttons -->
|
||||
<div class="mb-3">
|
||||
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="small"
|
||||
@click="handleQuickGenerate('ALLOWED')"
|
||||
class="text-none"
|
||||
block
|
||||
>
|
||||
<v-icon start size="16">mdi-check-circle</v-icon>
|
||||
Test ALLOWED
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="handleQuickGenerate('NOT_ALLOWED')"
|
||||
class="text-none"
|
||||
block
|
||||
>
|
||||
<v-icon start size="16">mdi-clock-alert</v-icon>
|
||||
Test NOT_ALLOWED
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Form Generate -->
|
||||
<v-form @submit.prevent="handleGenerate">
|
||||
<v-text-field
|
||||
v-model="localPatientId"
|
||||
label="ID Pasien"
|
||||
placeholder="Contoh: P-123456"
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-3"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details="auto"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="handleGenerateRandomId"
|
||||
title="Generate Random ID"
|
||||
>
|
||||
<v-icon size="20">mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-select
|
||||
v-model="localStatus"
|
||||
label="Status Check-in"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-shield-check"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-4"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
></v-select>
|
||||
|
||||
<div class="d-flex justify-center">
|
||||
<v-btn
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
type="submit"
|
||||
elevation="0"
|
||||
:disabled="!localPatientId"
|
||||
>
|
||||
<v-icon start size="20">mdi-qrcode-plus</v-icon>
|
||||
Generate QR Code
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
|
||||
<!-- QR Code Display -->
|
||||
<div v-if="props.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 ref="qrContainerRef" class="qr-code-container mb-4"></div>
|
||||
|
||||
<v-chip :color="localStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
|
||||
<v-icon start>{{ localStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
|
||||
{{ localStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
|
||||
</v-chip>
|
||||
|
||||
<p class="text-body-2 text-grey mb-4">
|
||||
Data: {{ props.generatedQRData }}
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="handleDownload"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-download</v-icon>
|
||||
Download
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="handleCopy"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-content-copy</v-icon>
|
||||
Copy
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="handleShare"
|
||||
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 #prepend>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</template>
|
||||
<strong>Cara menggunakan untuk Testing:</strong>
|
||||
<ol class="ml-4 mt-2">
|
||||
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
|
||||
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
|
||||
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
|
||||
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
|
||||
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
|
||||
</ol>
|
||||
</v-alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="800"
|
||||
persistent
|
||||
transition="dialog-transition"
|
||||
scrim="rgba(0, 0, 0, 0.5)"
|
||||
class="blur-dialog"
|
||||
>
|
||||
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
|
||||
<h2 class="text-h5 font-weight-bold text-white mb-2">
|
||||
<v-icon color="white" class="mr-2">mdi-history</v-icon>
|
||||
Riwayat Check-in
|
||||
</h2>
|
||||
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<!-- Filter dan Search -->
|
||||
<div class="mb-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
:model-value="search"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
label="Cari ID Pasien atau Nomor Antrean"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
:model-value="dateFilter"
|
||||
@update:model-value="$emit('update:dateFilter', $event || '')"
|
||||
type="date"
|
||||
label="Filter Tanggal"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
class="date-picker-field"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<v-select
|
||||
:model-value="statusFilter"
|
||||
@update:model-value="$emit('update:statusFilter', $event)"
|
||||
label="Filter Status"
|
||||
:items="statusOptions"
|
||||
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="$emit('clear')"
|
||||
:disabled="history.length === 0"
|
||||
>
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Hapus Semua
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-if="filteredHistory.length > 0" class="history-list">
|
||||
<v-card
|
||||
v-for="(item, index) in filteredHistory"
|
||||
:key="index"
|
||||
variant="outlined"
|
||||
class="mb-3 history-item"
|
||||
:class="getStatusClass(item.status)"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex justify-space-between align-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
|
||||
{{ getStatusText(item.status) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="grey-lighten-1"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
>
|
||||
{{ item.method }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<p class="text-body-1 font-weight-bold mb-1">
|
||||
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
|
||||
ID Pasien: {{ item.patientId }}
|
||||
</p>
|
||||
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
|
||||
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
|
||||
Nomor Antrean: {{ item.queueNumber }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-clock-outline</v-icon>
|
||||
{{ formatDateTime(item.checkInTime) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="item.checkInDate"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-calendar</v-icon>
|
||||
{{ formatDate(item.checkInDate) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="$emit('delete-item', index)"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
|
||||
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
|
||||
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
class="text-white font-weight-bold text-none"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CheckInHistoryItem, HistoryStatus } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
search: string
|
||||
dateFilter: string
|
||||
statusFilter: HistoryStatus | ''
|
||||
filteredHistory: CheckInHistoryItem[]
|
||||
history: CheckInHistoryItem[] | ReadonlyArray<CheckInHistoryItem>
|
||||
statusOptions: Array<{ title: string; value: string }>
|
||||
primaryColor: string
|
||||
getStatusColor: (status: string) => string
|
||||
getStatusIcon: (status: string) => string
|
||||
getStatusText: (status: string) => string
|
||||
getStatusClass: (status: string) => string
|
||||
formatDateTime: (dateString: string) => string
|
||||
formatDate: (dateString: string) => string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'update:search': [value: string]
|
||||
'update:dateFilter': [value: string]
|
||||
'update:statusFilter': [value: HistoryStatus | '']
|
||||
'delete-item': [index: number]
|
||||
'clear': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
'submit': [];
|
||||
'open-history': [];
|
||||
}>();
|
||||
|
||||
const manualForm = ref<{ validate: () => boolean; resetValidation: () => void; reset: () => void } | null>(null);
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit');
|
||||
};
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
emit('open-history');
|
||||
};
|
||||
|
||||
// Expose form ref for parent component
|
||||
defineExpose({
|
||||
form: manualForm,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="secondaryColor" size="24">mdi-keyboard</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">Input Manual</h3>
|
||||
<p class="status-subtitle">Masukkan nomor antrean atau ID pasien</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-form @submit.prevent="handleSubmit" ref="manualForm">
|
||||
<v-text-field
|
||||
v-model="localValue"
|
||||
label="Nomor Antrean / ID Pasien"
|
||||
placeholder="Contoh: P12345"
|
||||
prepend-inner-icon="mdi-account-card-details"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-4"
|
||||
required
|
||||
:rules="[v => !!v || 'Field ini wajib diisi']"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-icon :color="localValue ? 'success' : 'grey'" size="20">
|
||||
{{ localValue ? 'mdi-check-circle' : 'mdi-circle-outline' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
type="submit"
|
||||
elevation="0"
|
||||
>
|
||||
<v-icon start size="20">mdi-login</v-icon>
|
||||
Check-in Sekarang
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<!-- Quick Access Buttons -->
|
||||
<div class="quick-actions mt-4">
|
||||
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
class="text-none"
|
||||
@click="handleOpenHistory"
|
||||
>
|
||||
<v-icon start size="18">mdi-history</v-icon>
|
||||
Riwayat
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="800"
|
||||
persistent
|
||||
transition="dialog-transition"
|
||||
scrim="rgba(0, 0, 0, 0.5)"
|
||||
class="blur-dialog"
|
||||
>
|
||||
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #FB8C00 0%, #F57C00 100%);">
|
||||
<h2 class="text-h5 font-weight-bold text-white mb-2">
|
||||
<v-icon color="white" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||
Riwayat QR Scan
|
||||
</h2>
|
||||
<p class="text-body-2 text-white opacity-90">Daftar QR code yang telah di-scan</p>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<!-- Filter dan Search -->
|
||||
<div class="mb-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="5">
|
||||
<v-text-field
|
||||
:model-value="search"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
label="Cari Data QR Code"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
:model-value="dateFilter"
|
||||
@update:model-value="$emit('update:dateFilter', $event || '')"
|
||||
type="date"
|
||||
label="Filter Tanggal"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
class="date-picker-field"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="$emit('clear')"
|
||||
:disabled="history.length === 0"
|
||||
>
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Hapus Semua
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- QR History List -->
|
||||
<div v-if="filteredHistory.length > 0" class="history-list">
|
||||
<v-card
|
||||
v-for="(item, index) in filteredHistory"
|
||||
:key="index"
|
||||
variant="outlined"
|
||||
class="mb-3 history-item"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex justify-space-between align-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="mb-2">
|
||||
<p class="text-body-1 font-weight-bold mb-1">
|
||||
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-qrcode</v-icon>
|
||||
Data QR: {{ item.data }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-clock-outline</v-icon>
|
||||
{{ item.time }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-calendar</v-icon>
|
||||
{{ item.date }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="$emit('use-data', item.data)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="$emit('delete-item', index)"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-qrcode-scan</v-icon>
|
||||
<p class="text-h6 text-grey mb-2">Belum ada riwayat QR scan</p>
|
||||
<p class="text-body-2 text-grey">Riwayat QR scan akan muncul di sini setelah Anda melakukan scan</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
class="text-white font-weight-bold text-none"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ScannedQRHistoryItem } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
search: string
|
||||
dateFilter: string
|
||||
filteredHistory: ScannedQRHistoryItem[]
|
||||
history: ScannedQRHistoryItem[] | ReadonlyArray<ScannedQRHistoryItem>
|
||||
primaryColor: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'update:search': [value: string]
|
||||
'update:dateFilter': [value: string]
|
||||
'use-data': [data: string]
|
||||
'delete-item': [index: number]
|
||||
'clear': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isScanning: boolean;
|
||||
hasCamera: boolean;
|
||||
cameraChecking: boolean;
|
||||
cameraReady: boolean;
|
||||
primaryColor: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'start-scanning': [];
|
||||
'stop-scanning': [];
|
||||
'test-camera': [];
|
||||
'open-history': [];
|
||||
'open-qr-history': [];
|
||||
}>();
|
||||
|
||||
const handleStartScanning = () => {
|
||||
emit('start-scanning');
|
||||
};
|
||||
|
||||
const handleStopScanning = () => {
|
||||
emit('stop-scanning');
|
||||
};
|
||||
|
||||
const handleTestCamera = () => {
|
||||
emit('test-camera');
|
||||
};
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
emit('open-history');
|
||||
};
|
||||
|
||||
const handleOpenQRHistory = () => {
|
||||
emit('open-qr-history');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="primaryColor" size="24">mdi-qrcode-scan</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">
|
||||
{{ isScanning ? 'Arahkan Kamera ke QR Code' : 'Siap untuk Scan QR Code' }}
|
||||
</h3>
|
||||
<p class="status-subtitle">
|
||||
{{ isScanning ? 'Pastikan QR code terlihat jelas dan tidak terpotong' : 'Klik tombol di bawah untuk memulai scan' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Status Check -->
|
||||
<div v-if="cameraChecking" class="text-center mb-4">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
:color="primaryColor"
|
||||
size="24"
|
||||
class="mr-2"
|
||||
></v-progress-circular>
|
||||
<span class="text-body-2 text-grey">Memeriksa ketersediaan kamera...</span>
|
||||
</div>
|
||||
|
||||
<!-- Camera Not Available Warning -->
|
||||
<v-alert
|
||||
v-else-if="!hasCamera && !isScanning"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #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 tab <strong>Manual</strong> untuk input data secara manual.
|
||||
</p>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- QR Scanner Area dengan webcam -->
|
||||
<div class="qr-scanner-container mb-4">
|
||||
<div v-if="!isScanning" class="qr-placeholder">
|
||||
<div class="scanner-overlay">
|
||||
<div class="corner corner-tl"></div>
|
||||
<div class="corner corner-tr"></div>
|
||||
<div class="corner corner-bl"></div>
|
||||
<div class="corner corner-br"></div>
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<v-icon size="64" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
|
||||
</div>
|
||||
<div v-else class="qr-reader-container">
|
||||
<div class="scanner-status mb-2">
|
||||
<v-chip color="success" size="small" class="mr-2">
|
||||
<v-icon start size="16">mdi-camera</v-icon>
|
||||
Kamera Aktif
|
||||
</v-chip>
|
||||
<span class="text-caption text-grey">Preview kamera sedang berjalan</span>
|
||||
</div>
|
||||
<div id="qr-reader" class="qr-reader-wrapper">
|
||||
<div v-if="!cameraReady" class="scanner-loading-overlay">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="white"
|
||||
size="48"
|
||||
></v-progress-circular>
|
||||
<p class="text-white mt-4">Memuat kamera...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scanner-instruction">
|
||||
<v-icon :color="primaryColor" size="20" class="mr-2">mdi-information</v-icon>
|
||||
<span class="text-body-2">Arahkan kamera ke QR code. Pastikan QR code berada dalam kotak pemindaian.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button Minimalis -->
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
v-if="!isScanning"
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
elevation="0"
|
||||
@click="handleStartScanning"
|
||||
:disabled="!hasCamera && !cameraChecking"
|
||||
>
|
||||
<v-icon start size="20">mdi-camera</v-icon>
|
||||
{{ hasCamera ? 'Mulai Scan QR' : 'Kamera Tidak Tersedia' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
class="btn-stop-modern btn-centered"
|
||||
size="large"
|
||||
elevation="0"
|
||||
@click="handleStopScanning"
|
||||
>
|
||||
<v-icon start size="20">mdi-camera-off</v-icon>
|
||||
Stop Scan
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Info tambahan -->
|
||||
<div class="info-card mt-3">
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
:color="primaryColor"
|
||||
class="text-body-2 info-alert-centered"
|
||||
density="compact"
|
||||
>
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-icon size="16" class="mr-2">mdi-lightbulb-outline</v-icon>
|
||||
<span style="font-size: 12px;">Tips: Pastikan pencahayaan cukup untuk hasil scan optimal</span>
|
||||
</div>
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<!-- Test Camera Button (Debug) -->
|
||||
<div class="test-camera-section mt-2 mb-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="text-none btn-centered-small btn-test-camera"
|
||||
@click="handleTestCamera"
|
||||
>
|
||||
<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="handleOpenHistory"
|
||||
>
|
||||
<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="handleOpenQRHistory"
|
||||
>
|
||||
<v-icon start size="18" color="#1565C0">mdi-qrcode-scan</v-icon>
|
||||
Riwayat QR Scan
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="stats-footer-modern mt-3">
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ todayCount }}</div>
|
||||
<div class="stat-label">Hari Ini</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon color="success" size="20">mdi-check-circle</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ completedCount }}</div>
|
||||
<div class="stat-label">Selesai</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ pendingCount }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CheckInHistoryItem } from '~/types/checkin'
|
||||
import { PRIMARY_COLOR, SECONDARY_COLOR } from '~/constants/checkin'
|
||||
|
||||
interface Props {
|
||||
history: CheckInHistoryItem[]
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
primaryColor: PRIMARY_COLOR,
|
||||
secondaryColor: SECONDARY_COLOR,
|
||||
})
|
||||
|
||||
// Computed untuk menghitung stats
|
||||
const todayCount = computed(() => {
|
||||
const today = new Date().toDateString()
|
||||
return props.history.filter(item => {
|
||||
const itemDate = new Date(item.checkInDate).toDateString()
|
||||
return itemDate === today
|
||||
}).length
|
||||
})
|
||||
|
||||
const completedCount = computed(() => {
|
||||
return props.history.filter(item =>
|
||||
item.status === 'success' || item.status === 'ALLOWED'
|
||||
).length
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => {
|
||||
return props.history.filter(item =>
|
||||
item.status === 'pending' || item.status === 'NOT_ALLOWED'
|
||||
).length
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Stats Footer Modern */
|
||||
.stats-footer-modern {
|
||||
animation: slideUp 0.5s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card-modern {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card-modern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card-modern:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: #1565C0;
|
||||
}
|
||||
|
||||
.stat-card-modern:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.stat-icon-modern {
|
||||
margin-bottom: 12px;
|
||||
display: inline-flex;
|
||||
padding: 8px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user