From b5e40c68d21aa19458c2454be8a5fe1de51195f7 Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Mon, 5 Jan 2026 08:32:59 +0700 Subject: [PATCH] QRscan implementation --- .gitignore | 3 + ACCESS_DATABASE.md | 260 + HTTPS_SETUP.md | 61 + assets/scss/checkin/_components.scss | 882 ++++ assets/scss/checkin/_dialogs.scss | 230 + assets/scss/checkin/_variables.scss | 74 + components/checkin/CheckInDialog.vue | 171 + components/checkin/CheckInHeader.vue | 148 + components/checkin/GenerateQRTab.vue | 325 ++ components/checkin/HistoryDialog.vue | 214 + components/checkin/ManualInputTab.vue | 104 + components/checkin/QRHistoryDialog.vue | 179 + components/checkin/QRScanTab.vue | 218 + components/checkin/StatsFooter.vue | 146 + composables/useCheckIn.ts | 203 + composables/useCheckInHistory.ts | 276 + composables/useQRGenerator.ts | 211 + composables/useQRScanner.ts | 574 +++ composables/useSnackbar.ts | 44 + constants/checkin.ts | 23 + nuxt.config.ts | 44 +- package-lock.json | 7 + package.json | 3 + pages/CheckInPasien/checkIn.vue | 4420 +++++++++++------ .../checkIn.vue.refactore.backup | 717 +++ pages/Setting/HakAkses.vue | 831 +++- pages/Setting/UserLogin.vue | 677 ++- scripts/db-access.js | 145 + types/checkin.ts | 35 + 29 files changed, 9490 insertions(+), 1735 deletions(-) create mode 100644 ACCESS_DATABASE.md create mode 100644 HTTPS_SETUP.md create mode 100644 assets/scss/checkin/_components.scss create mode 100644 assets/scss/checkin/_dialogs.scss create mode 100644 assets/scss/checkin/_variables.scss create mode 100644 components/checkin/CheckInDialog.vue create mode 100644 components/checkin/CheckInHeader.vue create mode 100644 components/checkin/GenerateQRTab.vue create mode 100644 components/checkin/HistoryDialog.vue create mode 100644 components/checkin/ManualInputTab.vue create mode 100644 components/checkin/QRHistoryDialog.vue create mode 100644 components/checkin/QRScanTab.vue create mode 100644 components/checkin/StatsFooter.vue create mode 100644 composables/useCheckIn.ts create mode 100644 composables/useCheckInHistory.ts create mode 100644 composables/useQRGenerator.ts create mode 100644 composables/useQRScanner.ts create mode 100644 composables/useSnackbar.ts create mode 100644 constants/checkin.ts create mode 100644 pages/CheckInPasien/checkIn.vue.refactore.backup create mode 100644 scripts/db-access.js create mode 100644 types/checkin.ts diff --git a/.gitignore b/.gitignore index 81b4e1b..629f949 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ logs .env .env.* !.env.example +REFACTORING_STATUS.md +CHECKIN_DOCUMENTATION.md +USERLOGIN_DOCUMENTATION.md diff --git a/ACCESS_DATABASE.md b/ACCESS_DATABASE.md new file mode 100644 index 0000000..40d7451 --- /dev/null +++ b/ACCESS_DATABASE.md @@ -0,0 +1,260 @@ +# Cara Mengakses Database better-sqlite3 + +## šŸ“ Lokasi Database +- **Path relatif**: `data/users.db` +- **Path lengkap**: `E:\PROJECT\ddddd\Web-Antrean - Copy (2)\data\users.db` + +--- + +## šŸ”§ Metode 1: Menggunakan Node.js Script (Recommended) + +Karena project ini sudah menggunakan `better-sqlite3`, cara termudah adalah membuat script Node.js: + +### Contoh Script untuk Query Database + +```javascript +// scripts/query-db.js +const Database = require('better-sqlite3'); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'data', 'users.db'); +const db = new Database(dbPath); + +// Contoh: Get all users +const users = db.prepare('SELECT * FROM users').all(); +console.log('All users:', users); + +// Contoh: Get user by ID +const user = db.prepare('SELECT * FROM users WHERE id = ?').get('user-id-here'); +console.log('User:', user); + +// Contoh: Count users +const count = db.prepare('SELECT COUNT(*) as total FROM users').get(); +console.log('Total users:', count.total); + +db.close(); +``` + +**Jalankan dengan:** +```bash +node scripts/query-db.js +``` + +--- + +## šŸ”§ Metode 2: Menggunakan SQLite CLI + +### Install SQLite CLI (jika belum ada) + +**Windows:** +1. Download dari: https://www.sqlite.org/download.html +2. Atau install via Chocolatey: `choco install sqlite` +3. Atau install via Scoop: `scoop install sqlite` + +**Atau gunakan npx (tidak perlu install):** +```bash +npx sqlite3 data/users.db +``` + +### Perintah SQLite CLI + +```bash +# Buka database +sqlite3 data/users.db + +# Atau dengan npx (jika SQLite tidak terinstall) +npx sqlite3 data/users.db + +# Di dalam SQLite shell: +.tables # Lihat semua tabel +.schema users # Lihat schema tabel users +SELECT * FROM users; # Lihat semua data +SELECT * FROM users LIMIT 10; # Lihat 10 baris pertama +.mode column # Format output sebagai kolom +.headers on # Tampilkan header kolom +.quit # Keluar +``` + +--- + +## šŸ”§ Metode 3: Menggunakan GUI Tools + +### 1. **DB Browser for SQLite** (Gratis, Recommended) +- Download: https://sqlitebrowser.org/ +- Buka file: `data/users.db` +- Bisa edit, query, dan export data dengan mudah + +### 2. **SQLiteStudio** (Gratis) +- Download: https://sqlitestudio.pl/ +- Cross-platform, open source + +### 3. **DBeaver** (Gratis) +- Download: https://dbeaver.io/ +- Universal database tool, support banyak database termasuk SQLite + +### 4. **VS Code Extension** +- Install extension: **SQLite Viewer** atau **SQLite** +- Buka file `data/users.db` langsung di VS Code + +--- + +## šŸ”§ Metode 4: Menggunakan API Endpoints yang Sudah Ada + +Project ini sudah punya API endpoints untuk mengakses data: + +```bash +# Get all users +GET /api/users/list + +# Get current user +GET /api/users/current + +# Get user by ID +GET /api/users/[id] + +# Create user +POST /api/users/create + +# Update user +PATCH /api/users/[id] + +# Delete user +DELETE /api/users/[id] +``` + +--- + +## šŸ“Š Contoh Query Berguna + +### Melihat semua users: +```sql +SELECT * FROM users; +``` + +### Melihat users dengan format tanggal yang readable: +```sql +SELECT + id, + namaLengkap, + namaUser, + email, + datetime(lastLogin, 'unixepoch') as lastLoginFormatted, + datetime(createdAt, 'unixepoch') as createdAtFormatted +FROM users; +``` + +### Mencari user berdasarkan nama: +```sql +SELECT * FROM users WHERE namaLengkap LIKE '%nama%'; +``` + +### Mencari user berdasarkan username: +```sql +SELECT * FROM users WHERE namaUser = 'username'; +``` + +### Melihat users yang belum login: +```sql +SELECT * FROM users WHERE lastLogin IS NULL; +``` + +### Melihat users yang baru dibuat: +```sql +SELECT * FROM users ORDER BY createdAt DESC LIMIT 10; +``` + +### Export data ke CSV: +```sql +.mode csv +.headers on +.output users_export.csv +SELECT * FROM users; +.output stdout +``` + +--- + +## āš ļø Catatan Penting + +1. **Backup sebelum edit manual**: Selalu backup database sebelum melakukan perubahan manual +2. **Tutup koneksi**: Pastikan aplikasi tidak sedang menggunakan database saat mengedit manual +3. **Format timestamps**: `lastLogin`, `createdAt`, `updatedAt` disimpan sebagai Unix timestamp (seconds) +4. **JSON fields**: `roles`, `realmRoles`, `accountRoles`, `resourceRoles`, `groups` disimpan sebagai JSON string + +--- + +## šŸ› ļø Quick Access Script + +Buat file `scripts/db-access.js` untuk akses cepat: + +```javascript +const Database = require('better-sqlite3'); +const path = require('path'); +const readline = require('readline'); + +const dbPath = path.join(process.cwd(), 'data', 'users.db'); +const db = new Database(dbPath); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +console.log('šŸ“Š Database Access Tool'); +console.log('Database path:', dbPath); +console.log('\nAvailable commands:'); +console.log(' list - List all users'); +console.log(' count - Count total users'); +console.log(' schema - Show table schema'); +console.log(' query - Run custom SQL query'); +console.log(' exit - Exit\n'); + +function prompt() { + rl.question('> ', (input) => { + const [cmd, ...args] = input.trim().split(' '); + + try { + switch(cmd.toLowerCase()) { + case 'list': + const users = db.prepare('SELECT id, namaLengkap, namaUser, email FROM users').all(); + console.table(users); + break; + case 'count': + const count = db.prepare('SELECT COUNT(*) as total FROM users').get(); + console.log(`Total users: ${count.total}`); + break; + case 'schema': + const schema = db.prepare("PRAGMA table_info(users)").all(); + console.table(schema); + break; + case 'query': + const sql = args.join(' '); + if (!sql) { + console.log('Error: Please provide SQL query'); + break; + } + const result = db.prepare(sql).all(); + console.table(result); + break; + case 'exit': + db.close(); + rl.close(); + return; + default: + console.log('Unknown command. Type "exit" to quit.'); + } + } catch (error) { + console.error('Error:', error.message); + } + + prompt(); + }); +} + +prompt(); +``` + +Jalankan dengan: `node scripts/db-access.js` + + + diff --git a/HTTPS_SETUP.md b/HTTPS_SETUP.md new file mode 100644 index 0000000..159e4e3 --- /dev/null +++ b/HTTPS_SETUP.md @@ -0,0 +1,61 @@ +# Setup HTTPS untuk Development + +Aplikasi ini sudah dikonfigurasi untuk menggunakan HTTPS di development mode agar fitur kamera bisa berfungsi. + +## Cara Menggunakan + +1. **Jalankan aplikasi dengan HTTPS:** + ```bash + npm run dev:https + ``` + atau + ```bash + npm run dev + ``` + +2. **Akses aplikasi:** + - Dari komputer: `https://localhost:3001` + - Dari HP (dalam jaringan yang sama): `https://[IP-KOMPUTER]:3001` + - Contoh: `https://10.10.150.175:3001` + +## Peringatan Keamanan Browser + +Karena menggunakan self-signed certificate, browser akan menampilkan peringatan keamanan: + +### Chrome/Edge: +1. Klik **"Advanced"** atau **"Lanjutkan ke localhost (tidak aman)"** +2. Klik **"Proceed to localhost (unsafe)"** + +### Firefox: +1. Klik **"Advanced"** +2. Klik **"Accept the Risk and Continue"** + +### Safari (Mac): +1. Klik **"Show Details"** +2. Klik **"visit this website"** +3. Klik **"Visit Website"** di dialog konfirmasi + +### Mobile (HP): +- **Android Chrome**: Klik **"Advanced"** → **"Proceed to [IP] (unsafe)"** +- **iOS Safari**: Klik **"Advanced"** → **"Proceed to [IP]"** + +## Catatan Penting + +- āœ… Setelah menerima certificate, browser akan mengingatnya untuk kunjungan selanjutnya +- āœ… HTTPS diperlukan untuk akses kamera di browser modern +- āœ… Pastikan HP dan komputer dalam jaringan yang sama (WiFi yang sama) +- āœ… Gunakan IP komputer Anda, bukan `localhost` saat akses dari HP + +## Troubleshooting + +### Kamera masih tidak muncul? +1. Pastikan sudah menggunakan `https://` bukan `http://` +2. Cek izin kamera di browser settings +3. Pastikan tidak ada aplikasi lain yang menggunakan kamera +4. Restart browser setelah setup HTTPS pertama kali + +### Tidak bisa akses dari HP? +1. Pastikan firewall tidak memblokir port 3001 +2. Pastikan HP dan komputer dalam WiFi yang sama +3. Cek IP komputer dengan `ipconfig` (Windows) atau `ifconfig` (Mac/Linux) + diff --git a/assets/scss/checkin/_components.scss b/assets/scss/checkin/_components.scss new file mode 100644 index 0000000..b0c2d04 --- /dev/null +++ b/assets/scss/checkin/_components.scss @@ -0,0 +1,882 @@ +// Check-in Page Component Styles +@import 'variables'; + +/* Modern Minimalist Background */ +.bg-modern { + background: $bg-white; + min-height: 100vh; + max-height: 100vh; + position: relative; + overflow: hidden; +} + +.no-scroll-container { + height: 100vh; + max-height: 100vh; + overflow-y: auto; + overflow-x: hidden; +} + +.no-scroll-container::-webkit-scrollbar { + width: $spacing-xs; +} + +.no-scroll-container::-webkit-scrollbar-track { + background: transparent; +} + +.no-scroll-container::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 2px; +} + +.no-overflow { + overflow: hidden !important; + height: 100vh; + max-height: 100vh; +} + +.bg-modern::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(21, 101, 192, 0.05) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(21, 101, 192, 0.05) 0%, transparent 50%); + pointer-events: none; + z-index: 0; +} + +/* Main Card dengan Glassmorphism */ +.main-card { + background: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: $radius-xl !important; + box-shadow: $shadow-xl !important; + overflow: hidden; + position: relative; + z-index: $z-base; +} + +/* Header Modern Minimalis */ +.header-modern { + background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%); + padding: $spacing-xl $spacing-2xl $spacing-lg; + 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: $spacing-md; + margin-bottom: $spacing-xs; +} + +.icon-circle { + width: 56px; + height: 56px; + border-radius: $radius-full; + 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: $font-3xl; + font-weight: 600; + color: white; + margin: 0; + line-height: 1.2; +} + +.subtitle-modern { + font-size: $font-base; + color: rgba(255, 255, 255, 0.9); + margin: 2px 0 0; + font-weight: 400; +} + +.tabs-modern { + position: relative; + z-index: $z-base; +} + +.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 $transition-base; +} + +.tabs-modern :deep(.v-tab:hover) { + color: rgba(255, 255, 255, 1) !important; + background: rgba(255, 255, 255, 0.1); + border-radius: $radius-sm; +} + +.tabs-modern :deep(.v-tab--selected) { + color: white !important; + font-weight: 600; +} + +.tabs-modern :deep(.v-slider) { + background-color: $secondary-color !important; + height: 3px; + border-radius: 2px; +} + +.tab-modern { + display: flex; + align-items: center; + gap: 6px; +} + +.tab-modern .v-icon { + font-size: $font-xl !important; +} + +/* Content Area */ +.content-modern { + background: white; + max-height: calc(100vh - 200px); + overflow-y: auto; +} + +.content-modern::-webkit-scrollbar { + width: $spacing-xs; +} + +.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 $transition-slow ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Status Header */ +.status-header { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-md; + padding: $spacing-lg; + background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%); + border-radius: $radius-md; + border: 1px solid rgba(21, 101, 192, 0.1); + text-align: center; +} + +.status-icon-wrapper { + width: 40px; + height: 40px; + border-radius: 10px; + background: white; + display: flex; + align-items: center; + justify-content: center; + box-shadow: $shadow-sm; + flex-shrink: 0; + margin: 0 auto; +} + +.status-text { + width: 100%; + text-align: center; +} + +.status-title { + font-size: $font-lg; + font-weight: 600; + color: $text-primary; + margin: 0 0 $spacing-xs; + letter-spacing: -0.3px; + text-align: center; +} + +.status-subtitle { + font-size: $font-base; + color: $text-secondary; + margin: 0; + line-height: 1.4; + text-align: center; +} + +/* QR Scanner Modern */ +.qr-scanner-container { + display: flex; + justify-content: center; + align-items: center; + margin: $spacing-2xl 0; +} + +.qr-placeholder { + width: 100%; + max-width: 300px; + height: 300px; + background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%); + border-radius: $radius-lg; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 2px dashed $primary-color; + box-shadow: 0 4px 20px rgba(21, 101, 192, 0.1); + margin: 0 auto; +} + +.qr-reader-container { + width: 100%; + max-width: 500px; + margin: 0 auto; + position: relative; +} + +.qr-reader-wrapper { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: $radius-lg; + overflow: hidden; + box-shadow: $shadow-xl; + background: #000; + position: relative; + max-width: 500px; + max-height: 500px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid rgba(21, 101, 192, 0.2); + margin: 0 auto; +} + +.qr-reader-wrapper :deep(video) { + width: 100% !important; + height: 100% !important; + border-radius: $radius-lg; + display: block !important; + object-fit: cover; + background: #000; + aspect-ratio: 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: $radius-lg; + border: 3px solid rgba(21, 101, 192, 0.8) !important; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4) !important; + position: relative; +} + +.qr-reader-wrapper :deep(#qr-reader__scan_region::before) { + content: ''; + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + border: 3px solid rgba(21, 101, 192, 0.8); + border-radius: $radius-lg; + 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: $radius-lg; + width: 100% !important; + height: 100% !important; + aspect-ratio: 1 / 1; + object-fit: cover; +} + +.scanner-status { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + font-size: $font-sm; +} + +.scanner-instruction { + display: flex; + align-items: center; + justify-content: center; + margin-top: $spacing-md; + padding: 10px $spacing-md; + background: linear-gradient(135deg, rgba(21, 101, 192, 0.1) 0%, rgba(13, 71, 161, 0.1) 100%); + border-radius: 10px; + color: $primary-color; + font-weight: 500; + font-size: $font-sm; + text-align: center; + border: 1px solid rgba(21, 101, 192, 0.2); +} + +.scanner-loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + border-radius: $radius-lg; + z-index: $z-overlay; +} + +.scanner-overlay { + position: absolute; + width: 80%; + height: 80%; +} + +.corner { + position: absolute; + width: 40px; + height: 40px; + border: 3px solid $primary-color; +} + +.corner-tl { + top: 0; + left: 0; + border-right: none; + border-bottom: none; + border-radius: $radius-sm 0 0 0; +} + +.corner-tr { + top: 0; + right: 0; + border-left: none; + border-bottom: none; + border-radius: 0 $radius-sm 0 0; +} + +.corner-bl { + bottom: 0; + left: 0; + border-right: none; + border-top: none; + border-radius: 0 0 0 $radius-sm; +} + +.corner-br { + bottom: 0; + right: 0; + border-left: none; + border-top: none; + border-radius: 0 0 $radius-sm 0; +} + +.scan-line { + position: absolute; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, $primary-color, transparent); + top: 0; + animation: scan 2s linear infinite; + box-shadow: 0 0 10px $primary-color; +} + +@keyframes scan { + 0% { top: 0; } + 100% { top: 100%; } +} + +.qr-icon { + position: relative; + z-index: $z-base; + 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, $primary-color 0%, $primary-dark 100%) !important; + color: white !important; + font-weight: 600; + text-transform: none; + letter-spacing: 0.3px; + border-radius: $radius-md !important; + padding: 14px $spacing-2xl !important; + transition: all $transition-bezier; + box-shadow: $shadow-primary !important; +} + +.btn-primary-modern:hover { + transform: translateY(-2px); + box-shadow: $shadow-primary-hover !important; +} + +.btn-primary-modern:active { + transform: translateY(0); +} + +.btn-primary-modern:disabled { + opacity: 0.5; + transform: none !important; +} + +.btn-stop-modern { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important; + color: white !important; + font-weight: 600; + text-transform: none; + letter-spacing: 0.3px; + border-radius: $radius-md !important; + padding: 14px $spacing-2xl !important; + transition: all $transition-bezier; + box-shadow: $shadow-error !important; +} + +.btn-stop-modern:hover { + transform: translateY(-2px); + box-shadow: $shadow-error-hover !important; +} + +.action-buttons { + margin-top: $spacing-xl; + 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: $primary-color !important; + color: $primary-color !important; +} + +.btn-test-camera :deep(.v-btn__content) { + color: $primary-color !important; +} + +.btn-test-camera :deep(.v-icon) { + color: $primary-color !important; + opacity: 1 !important; +} + +/* Modern Inputs */ +.input-modern :deep(.v-field) { + border-radius: $radius-md; + font-size: $font-md; + background: $bg-light; + border: 1.5px solid $border-light; + transition: all $transition-base; +} + +.input-modern :deep(.v-field--focused) { + background: white; + border-color: $primary-color; + box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.1); +} + +.input-modern :deep(.v-field__input) { + padding-top: $spacing-md; + padding-bottom: $spacing-md; +} + +.input-modern :deep(.v-label) { + font-weight: 500; + color: $text-secondary; +} + +.quick-actions { + margin-top: $spacing-lg; + padding-top: $spacing-lg; + border-top: 1px solid $border-light; +} + +.info-card { + display: flex; + justify-content: center; +} + +.info-alert-centered { + width: 100%; +} + +.info-alert-centered :deep(.v-alert__content) { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; +} + +.quick-actions .v-btn { + transition: all $transition-base; + border-radius: $radius-md !important; + border: 1.5px solid $border-light !important; +} + +.quick-actions .v-btn:hover { + transform: translateY(-2px); + border-color: $primary-color !important; + box-shadow: 0 4px 12px rgba(21, 101, 192, 0.15); +} + +.quick-actions .v-btn :deep(.v-icon) { + opacity: 1 !important; + color: inherit !important; +} + +.quick-actions .v-btn[color="primary"] { + color: $primary-color !important; + border-color: $primary-color !important; +} + +.quick-actions .v-btn[color="primary"] :deep(.v-icon) { + color: $primary-color !important; +} + +.qr-code-container { + display: flex; + justify-content: center; + align-items: center; + padding: $spacing-lg; + background: white; + border-radius: $radius-md; + box-shadow: $shadow-md; +} + +.qr-code-container :deep(canvas) { + border-radius: $radius-sm; +} + +.qr-display { + animation: slideUp 0.5s ease-out; +} + +/* 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: $radius-md; + padding: $spacing-lg $spacing-md; + text-align: center; + border: 1px solid $border-light; + transition: all $transition-bezier; + position: relative; + overflow: hidden; +} + +.stat-card-modern::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, $primary-color 0%, $primary-dark 100%); + transform: scaleX(0); + transition: transform $transition-base; +} + +.stat-card-modern:hover { + transform: translateY(-4px); + box-shadow: $shadow-lg; + border-color: $primary-color; +} + +.stat-card-modern:hover::before { + transform: scaleX(1); +} + +.stat-icon-modern { + margin-bottom: $spacing-md; + display: inline-flex; + padding: $spacing-sm; + background: $bg-blue-light; + border-radius: 10px; +} + +.stat-value { + font-size: $font-2xl; + font-weight: 700; + color: $text-primary; + margin-bottom: $spacing-xs; + letter-spacing: -0.5px; +} + +.stat-label { + font-size: $font-sm; + color: $text-secondary; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.custom-snackbar { + margin-bottom: $spacing-lg; + margin-right: $spacing-lg; +} + +.custom-snackbar :deep(.v-snackbar__wrapper) { + min-width: 300px; +} + +/* History Dialog Styles */ +.history-list { + max-height: 500px; + overflow-y: auto; + padding-right: $spacing-sm; +} + +.history-list::-webkit-scrollbar { + width: 6px; +} + +.history-list::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +.history-list::-webkit-scrollbar-thumb { + background: #888; + border-radius: 10px; +} + +.history-list::-webkit-scrollbar-thumb:hover { + background: #555; +} + +.history-item { + transition: all $transition-base; + border-left: 4px solid transparent; +} + +.history-item:hover { + transform: translateX(4px); + box-shadow: $shadow-md; +} + +.history-success { + border-left-color: #4caf50; + background: rgba(76, 175, 80, 0.05); +} + +.history-failed { + border-left-color: #f44336; + background: rgba(244, 67, 54, 0.05); +} + +.history-pending { + border-left-color: #ff9800; + background: rgba(255, 152, 0, 0.05); +} + +/* Mobile Optimization */ +@media (max-width: 600px) { + .v-container.no-scroll-container { + padding: $spacing-sm !important; + } + + .main-card { + border-radius: $radius-lg !important; + } + + .header-modern { + padding: $spacing-lg $spacing-lg $spacing-md !important; + } + + .content-modern { + padding: $spacing-lg !important; + max-height: calc(100vh - 180px); + } + + .header-content { + flex-direction: column; + text-align: center; + gap: $spacing-md; + } + + .header-text { + text-align: center; + } + + .icon-circle { + width: 56px; + height: 56px; + } + + .title-modern { + font-size: $font-2xl; + } + + .subtitle-modern { + font-size: $font-base; + } + + .content-modern { + padding: $spacing-xl $spacing-lg !important; + } + + .status-header { + padding: $spacing-lg; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: $spacing-md; + } + + .status-icon-wrapper { + margin: 0 auto; + } + + .status-text { + text-align: center; + width: 100%; + } + + .status-title { + font-size: $font-lg; + } + + .status-subtitle { + font-size: $font-base; + } + + .qr-placeholder { + max-width: 100%; + height: 280px; + } + + .qr-reader-container { + max-width: 100%; + } + + .qr-reader-wrapper { + min-height: 300px; + max-height: 70vh; + border-radius: $radius-lg; + } + + .qr-reader-wrapper :deep(video) { + max-height: 70vh; + object-fit: contain; + } + + .scanner-instruction { + font-size: $font-sm; + padding: $spacing-md; + } + + .stats-footer-modern { + margin-top: $spacing-sm; + } + + .stat-card-modern { + padding: $spacing-md $spacing-sm; + } + + .stat-value { + font-size: $font-xl; + } + + .stat-label { + font-size: $font-xs; + } + + .stat-icon-modern { + margin-bottom: $spacing-sm; + padding: 6px; + } +} diff --git a/assets/scss/checkin/_dialogs.scss b/assets/scss/checkin/_dialogs.scss new file mode 100644 index 0000000..9138abb --- /dev/null +++ b/assets/scss/checkin/_dialogs.scss @@ -0,0 +1,230 @@ +// Check-in Page Dialog Styles +@import 'variables'; + +.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, $success-color 0%, $success-dark 100%); +} + +.warning-header { + background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%); +} + +.error-header { + background: linear-gradient(135deg, $error-color 0%, $error-dark 100%); +} + +.icon-bg-error { + background: linear-gradient(135deg, $error-color 0%, $error-dark 100%); +} + +.dialog-button { + text-transform: none; + letter-spacing: 0.5px; + font-weight: 600; + transition: all $transition-base; +} + +.dialog-button:hover { + transform: translateY(-2px); +} + +/* Patient Info Card Styling */ +.patient-info-card { + background: rgba(250, 250, 250, 0.8) !important; + border-color: $border-grey !important; +} + +.patient-info-label { + color: $text-grey !important; + font-weight: 500; + opacity: 0.9; +} + +.patient-info-value { + color: $text-grey-dark !important; + font-weight: 500 !important; + opacity: 0.85; +} + +.patient-info-icon { + opacity: 0.7; +} + +/* Decorative Circles */ +.decorative-circles { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; +} + +.circle { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + animation: float 6s ease-in-out infinite; +} + +.circle-1 { + width: 100px; + height: 100px; + top: -50px; + left: -50px; + animation-delay: 0s; +} + +.circle-2 { + width: 150px; + height: 150px; + top: 50%; + right: -75px; + animation-delay: 2s; +} + +.circle-3 { + width: 80px; + height: 80px; + bottom: -40px; + left: 20%; + animation-delay: 4s; +} + +@keyframes float { + 0%, 100% { + transform: translate(0, 0) scale(1); + opacity: 0.3; + } + 50% { + transform: translate(20px, -20px) scale(1.1); + opacity: 0.5; + } +} + +/* Message Container */ +.message-container { + text-align: center; +} + +.message-icon { + display: flex; + justify-content: center; + align-items: center; +} + +/* Status Badge */ +.status-badge { + display: inline-flex; +} + +/* Instruction Box */ +.instruction-box { + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.instruction-success { + background: rgba(76, 175, 80, 0.1); + border-color: rgba(76, 175, 80, 0.3); +} + +.instruction-warning { + background: rgba(255, 152, 0, 0.1); + border-color: rgba(255, 152, 0, 0.3); +} + +/* Icon Background */ +.icon-bg { + width: 120px; + height: 120px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + position: relative; + z-index: 1; +} + +.icon-bg-success { + background: linear-gradient(135deg, $success-color 0%, $success-dark 100%); +} + +.icon-bg-warning { + background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%); +} + +.icon-bg-error { + background: linear-gradient(135deg, $error-color 0%, $error-dark 100%); +} diff --git a/assets/scss/checkin/_variables.scss b/assets/scss/checkin/_variables.scss new file mode 100644 index 0000000..bb52b37 --- /dev/null +++ b/assets/scss/checkin/_variables.scss @@ -0,0 +1,74 @@ +// Check-in Page Variables +// Colors +$primary-color: #1565C0; +$primary-dark: #0D47A1; +$secondary-color: #FB8C00; +$success-color: #66BB6A; +$success-dark: #43A047; +$warning-color: #FFA726; +$warning-dark: #FB8C00; +$error-color: #EF5350; +$error-dark: #E53935; + +// Background Colors +$bg-white: #ffffff; +$bg-light: #fafafa; +$bg-grey: #f5f7fa; +$bg-blue-light: #e3f2fd; + +// Text Colors +$text-primary: #1a1a1a; +$text-secondary: #6b7280; +$text-grey: #9e9e9e; +$text-grey-dark: #757575; + +// Border Colors +$border-light: #e5e7eb; +$border-grey: rgba(0, 0, 0, 0.08); + +// Shadow +$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08); +$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.1); +$shadow-primary: 0 4px 16px rgba(21, 101, 192, 0.3); +$shadow-primary-hover: 0 8px 24px rgba(21, 101, 192, 0.4); +$shadow-error: 0 4px 16px rgba(239, 68, 68, 0.3); +$shadow-error-hover: 0 8px 24px rgba(239, 68, 68, 0.4); + +// Border Radius +$radius-sm: 8px; +$radius-md: 12px; +$radius-lg: 16px; +$radius-xl: 24px; +$radius-full: 50%; + +// Spacing +$spacing-xs: 4px; +$spacing-sm: 8px; +$spacing-md: 12px; +$spacing-lg: 16px; +$spacing-xl: 20px; +$spacing-2xl: 24px; +$spacing-3xl: 28px; + +// Font Sizes +$font-xs: 10px; +$font-sm: 11px; +$font-base: 13px; +$font-md: 15px; +$font-lg: 16px; +$font-xl: 18px; +$font-2xl: 20px; +$font-3xl: 24px; + +// Transitions +$transition-fast: 0.2s ease; +$transition-base: 0.3s ease; +$transition-slow: 0.4s ease; +$transition-bezier: 0.3s cubic-bezier(0.4, 0, 0.2, 1); + +// Z-index +$z-base: 1; +$z-overlay: 10; +$z-dialog: 100; diff --git a/components/checkin/CheckInDialog.vue b/components/checkin/CheckInDialog.vue new file mode 100644 index 0000000..59d426e --- /dev/null +++ b/components/checkin/CheckInDialog.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/components/checkin/CheckInHeader.vue b/components/checkin/CheckInHeader.vue new file mode 100644 index 0000000..d5dee51 --- /dev/null +++ b/components/checkin/CheckInHeader.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/components/checkin/GenerateQRTab.vue b/components/checkin/GenerateQRTab.vue new file mode 100644 index 0000000..6d31aa7 --- /dev/null +++ b/components/checkin/GenerateQRTab.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/components/checkin/HistoryDialog.vue b/components/checkin/HistoryDialog.vue new file mode 100644 index 0000000..366e0b4 --- /dev/null +++ b/components/checkin/HistoryDialog.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/components/checkin/ManualInputTab.vue b/components/checkin/ManualInputTab.vue new file mode 100644 index 0000000..6ce0649 --- /dev/null +++ b/components/checkin/ManualInputTab.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/components/checkin/QRHistoryDialog.vue b/components/checkin/QRHistoryDialog.vue new file mode 100644 index 0000000..3e37959 --- /dev/null +++ b/components/checkin/QRHistoryDialog.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/components/checkin/QRScanTab.vue b/components/checkin/QRScanTab.vue new file mode 100644 index 0000000..3aee18c --- /dev/null +++ b/components/checkin/QRScanTab.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/components/checkin/StatsFooter.vue b/components/checkin/StatsFooter.vue new file mode 100644 index 0000000..3b84410 --- /dev/null +++ b/components/checkin/StatsFooter.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/composables/useCheckIn.ts b/composables/useCheckIn.ts new file mode 100644 index 0000000..509eb62 --- /dev/null +++ b/composables/useCheckIn.ts @@ -0,0 +1,203 @@ +import { ref, readonly } from 'vue' +import type { CheckInResult } from '~/types/checkin' + +export interface UseCheckInOptions { + showSnackbar: (title: string, message: string, color: string, icon: string) => void + saveToHistory: (item: { + patientId: string + queueNumber?: string + status: string + checkInTime: string + checkInDate: string + method: string + }) => void + saveSuccessfulScan: (qrData: string) => void + onCheckInSuccess: (result: { + success: boolean + patientId: string + status: string + message: string + action: 'checkin' | 'kembali' + }) => void + autoCloseDialog: () => void +} + +export const useCheckIn = (options: UseCheckInOptions) => { + const { showSnackbar, saveToHistory, saveSuccessfulScan, onCheckInSuccess, autoCloseDialog } = options + + // State + const lastCheckInResult = ref(null) + + // Perform check-in + const performCheckIn = async (data: string, method: string = 'QR Scan'): Promise => { + await new Promise(resolve => setTimeout(resolve, 1000)) + const success = Math.random() < 0.8 + + // History akan disimpan di processQRCode setelah performCheckIn selesai + // Jadi kita hanya perlu return hasil check-in + return success + } + + // Process QR code (rename dari onDetect) + const processQRCode = async (decodedText: string) => { + const [patientId, status] = decodedText.split('|') + + // Validasi format QR code + if (!patientId || !status) { + showSnackbar('Error', 'QR Code tidak valid. Format harus: ID_PASIEN|STATUS', 'error', 'mdi-close-circle') + return + } + + // Cek apakah pasien diperbolehkan check-in + const isAllowed = status === 'ALLOWED' + + if (isAllowed) { + // Jika diperbolehkan, langsung proses check-in + const checkinSuccess = await performCheckIn(decodedText, 'QR Scan') + + // Simpan hasil check-in + lastCheckInResult.value = { + success: checkinSuccess, + patientId: patientId, + status: status + } + + // Simpan ke history check-in dengan status hasil check-in + saveToHistory({ + patientId: patientId || 'Unknown', + status: checkinSuccess ? 'success' : 'failed', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'QR Scan' + }) + + // Tampilkan dialog dengan hasil check-in + if (checkinSuccess) { + // Simpan QR code yang berhasil di-scan untuk mencegah scan ulang + saveSuccessfulScan(decodedText) + onCheckInSuccess({ + success: true, + patientId: patientId, + status: status, + message: `āœ… Check-in Berhasil!\n\nPasien ${patientId} berhasil melakukan check-in.`, + action: 'checkin' + }) + } else { + onCheckInSuccess({ + success: false, + patientId: patientId, + status: status, + message: `āŒ Check-in Gagal!\n\nPasien ${patientId} diperbolehkan check-in, namun proses check-in gagal. Silakan coba lagi.`, + action: 'checkin' + }) + } + + autoCloseDialog() + } else { + // Jika belum diperbolehkan, tampilkan pesan + lastCheckInResult.value = { + success: false, + patientId: patientId, + status: status + } + + // Simpan ke history check-in untuk NOT_ALLOWED + saveToHistory({ + patientId: patientId || 'Unknown', + status: 'NOT_ALLOWED', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'QR Scan' + }) + + onCheckInSuccess({ + success: false, + patientId: patientId, + status: status, + message: `ā³ Belum Diizinkan Check-in\n\nAntrean Pasien ${patientId} belum diperbolehkan check-in. Mohon menunggu hingga antrean Anda dipanggil.`, + action: 'kembali' + }) + + autoCloseDialog() + } + } + + // Check-in manual + const checkInManual = async ( + patientId: string, + onSuccess: () => void, + onError: () => void + ) => { + try { + if (!patientId || !patientId.trim()) { + showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert') + onError() + return + } + + const trimmedPatientId = patientId.trim() + + // Simulasi check-in manual + const success = await performCheckIn(`${trimmedPatientId}|ALLOWED`, 'Manual') + + // Simpan hasil check-in + lastCheckInResult.value = { + success: success, + patientId: trimmedPatientId, + status: 'ALLOWED', + } + + // Simpan ke history check-in + saveToHistory({ + patientId: trimmedPatientId, + status: success ? 'success' : 'failed', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'Manual' + }) + + if (success) { + // Simpan QR code yang berhasil untuk mencegah double antrian (jika menggunakan format yang sama) + saveSuccessfulScan(`${trimmedPatientId}|ALLOWED`) + showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle') + + // Update info dialog + onCheckInSuccess({ + success: true, + patientId: trimmedPatientId, + status: 'ALLOWED', + message: `āœ… Check-in Berhasil!\n\nPasien ${trimmedPatientId} berhasil melakukan check-in secara manual.`, + action: 'checkin' + }) + + autoCloseDialog() + onSuccess() + } else { + showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle') + + // Update info dialog untuk gagal + onCheckInSuccess({ + success: false, + patientId: trimmedPatientId, + status: 'ALLOWED', + message: `āŒ Check-in Gagal!\n\nPasien ${trimmedPatientId} gagal melakukan check-in secara manual. Silakan coba lagi.`, + action: 'checkin' + }) + + autoCloseDialog() + onError() + } + } catch (error) { + console.error('Error in checkInManual:', error) + showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert') + onError() + } + } + + return { + lastCheckInResult: readonly(lastCheckInResult), + performCheckIn, + processQRCode, + checkInManual, + } +} diff --git a/composables/useCheckInHistory.ts b/composables/useCheckInHistory.ts new file mode 100644 index 0000000..897a180 --- /dev/null +++ b/composables/useCheckInHistory.ts @@ -0,0 +1,276 @@ +import { ref, computed, readonly } from 'vue' +import type { CheckInHistoryItem, ScannedQRHistoryItem, HistoryStatus } from '~/types/checkin' +import { HISTORY_STORAGE_KEY, SCANNED_QR_STORAGE_KEY, MAX_HISTORY_ITEMS, MAX_SCANNED_QR_ITEMS, HISTORY_STATUS_OPTIONS } from '~/constants/checkin' + +export const useCheckInHistory = () => { + const checkInHistory = ref([]) + const scannedQRHistory = ref([]) + const historySearch = ref('') + const historyStatusFilter = ref('') + const historyDateFilter = ref('') + + 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: CheckInHistoryItem) => { + const historyItem = { + ...item, + queueNumber: item.queueNumber || `ANT-${Date.now()}`, + } + + checkInHistory.value.unshift(historyItem) + + // Simpan maksimal item sesuai constant + if (checkInHistory.value.length > MAX_HISTORY_ITEMS) { + checkInHistory.value = checkInHistory.value.slice(0, MAX_HISTORY_ITEMS) + } + + if (typeof window !== 'undefined') { + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value)) + } + } + + const deleteHistoryItem = (index: number) => { + checkInHistory.value.splice(index, 1) + if (typeof window !== 'undefined') { + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value)) + } + } + + const clearHistory = () => { + checkInHistory.value = [] + if (typeof window !== 'undefined') { + localStorage.removeItem(HISTORY_STORAGE_KEY) + } + } + + const loadScannedQRHistory = () => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(SCANNED_QR_STORAGE_KEY) + if (stored) { + try { + scannedQRHistory.value = JSON.parse(stored) + } catch (e) { + console.error('Error loading QR history:', e) + scannedQRHistory.value = [] + } + } else { + scannedQRHistory.value = [] + } + } + } + + const saveScannedQRData = (qrData: string) => { + if (typeof window !== 'undefined') { + const scannedQRs = JSON.parse(localStorage.getItem(SCANNED_QR_STORAGE_KEY) || '[]') + scannedQRs.unshift({ + data: qrData, + timestamp: new Date().toISOString(), + date: new Date().toLocaleDateString('id-ID'), + time: new Date().toLocaleTimeString('id-ID') + }) + + // Simpan maksimal item sesuai constant + if (scannedQRs.length > MAX_SCANNED_QR_ITEMS) { + scannedQRs.pop() + } + + localStorage.setItem(SCANNED_QR_STORAGE_KEY, JSON.stringify(scannedQRs)) + } + } + + const clearQRHistory = () => { + scannedQRHistory.value = [] + if (typeof window !== 'undefined') { + localStorage.removeItem(SCANNED_QR_STORAGE_KEY) + } + } + + const deleteQRHistoryItem = (index: number) => { + scannedQRHistory.value.splice(index, 1) + if (typeof window !== 'undefined') { + localStorage.setItem(SCANNED_QR_STORAGE_KEY, JSON.stringify(scannedQRHistory.value)) + } + } + + const filteredHistory = computed(() => { + let filtered = [...checkInHistory.value] + + // Filter by search + if (historySearch.value) { + const search = historySearch.value.toLowerCase() + filtered = filtered.filter(item => + item.patientId.toLowerCase().includes(search) || + (item.queueNumber && item.queueNumber.toLowerCase().includes(search)) + ) + } + + // Filter by status + if (historyStatusFilter.value) { + filtered = filtered.filter(item => { + if (historyStatusFilter.value === 'success') { + return item.status === 'ALLOWED' || item.status === 'success' + } else if (historyStatusFilter.value === 'failed') { + return item.status === 'NOT_ALLOWED' || item.status === 'failed' + } + return true + }) + } + + // Filter by date + if (historyDateFilter.value && historyDateFilter.value.trim() !== '') { + filtered = filtered.filter(item => { + if (!item.checkInDate) return false + try { + const itemDate = new Date(item.checkInDate) + const filterDate = new Date(historyDateFilter.value) + + // Validate dates + if (isNaN(itemDate.getTime()) || isNaN(filterDate.getTime())) { + return false + } + + // Compare dates (year, month, day only, ignore time) + const itemDateStr = itemDate.toISOString().substring(0, 10) + const filterDateStr = filterDate.toISOString().substring(0, 10) + + return itemDateStr === filterDateStr + } catch (e) { + console.error('Error filtering by date:', e) + return false + } + }) + } + + return filtered + }) + + 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) + ) + } + + // Filter by date + if (historyDateFilter.value && historyDateFilter.value.trim() !== '') { + filtered = filtered.filter(item => { + if (!item.timestamp && !item.date) return false + try { + let itemDate: Date + if (item.timestamp) { + itemDate = new Date(item.timestamp) + } else { + // Parse Indonesian date format (dd/mm/yyyy or dd mmm yyyy) + itemDate = new Date(item.date) + } + const filterDate = new Date(historyDateFilter.value) + + // Validate dates + if (isNaN(itemDate.getTime()) || isNaN(filterDate.getTime())) { + return false + } + + // Compare dates (year, month, day only, ignore time) + const itemDateStr = itemDate.toISOString().substring(0, 10) + const filterDateStr = filterDate.toISOString().substring(0, 10) + + return itemDateStr === filterDateStr + } catch (e) { + console.error('Error filtering QR history by date:', e) + return false + } + }) + } + + return filtered + }) + + const getStatusColor = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'success' + if (status === 'NOT_ALLOWED' || status === 'failed') return 'error' + return 'warning' + } + + const getStatusIcon = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle' + if (status === 'NOT_ALLOWED' || 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' || status === 'failed') return 'Gagal' + return 'Pending' + } + + const getStatusClass = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'history-success' + if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed' + return 'history-pending' + } + + const formatDateTime = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleTimeString('id-ID', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric' + }) + } + + // Load history on initialization + if (typeof window !== 'undefined') { + loadHistory() + loadScannedQRHistory() + } + + return { + checkInHistory: readonly(checkInHistory), + scannedQRHistory: readonly(scannedQRHistory), + historySearch, + historyStatusFilter, + historyDateFilter, + filteredHistory, + filteredQRHistory, + loadHistory, + saveToHistory, + deleteHistoryItem, + clearHistory, + loadScannedQRHistory, + saveScannedQRData, + clearQRHistory, + deleteQRHistoryItem, + getStatusColor, + getStatusIcon, + getStatusText, + getStatusClass, + formatDateTime, + formatDate, + historyStatusOptions: [...HISTORY_STATUS_OPTIONS] as Array<{ title: string; value: string }>, + } +} diff --git a/composables/useQRGenerator.ts b/composables/useQRGenerator.ts new file mode 100644 index 0000000..92c0418 --- /dev/null +++ b/composables/useQRGenerator.ts @@ -0,0 +1,211 @@ +import { ref, readonly, computed } from 'vue' +import { nextTick } from 'vue' +import type { CheckInStatus } from '~/types/checkin' + +export interface UseQRGeneratorOptions { + showSnackbar: (title: string, message: string, color: string, icon: string) => void + generateRandomPatientId: () => string +} + +export const useQRGenerator = (options: UseQRGeneratorOptions) => { + const { showSnackbar, generateRandomPatientId } = options + + // State + const generatePatientId = ref(generateRandomPatientId()) + const generateStatus = ref('ALLOWED') + const generatedQRData = ref('') + + // Generate random patient ID + const generateRandomId = () => { + generatePatientId.value = generateRandomPatientId() + } + + // Quick generate QR for testing + const generateQuickQR = (patientId: string, status: string) => { + generatePatientId.value = patientId + generateStatus.value = status as CheckInStatus + generateQRCode() + } + + // 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 = '' + + try { + // Use qrcode package that's already installed + const QRCode = (await import('qrcode')).default + + // Create QR code as data URL + const qrDataUrl = await QRCode.toDataURL(generatedQRData.value, { + errorCorrectionLevel: 'M', + type: 'image/png', + quality: 0.92, + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + width: 300 + }) + + // Create img element and append to container + const img = document.createElement('img') + img.src = qrDataUrl + img.alt = 'QR Code' + img.style.width = '100%' + img.style.maxWidth = '300px' + img.style.height = 'auto' + img.style.display = 'block' + img.style.margin = '0 auto' + qrContainer.appendChild(img) + + console.log('QR Code created successfully:', generatedQRData.value) + showSnackbar('Berhasil!', 'QR Code berhasil di-generate. Silakan scan untuk testing', 'success', 'mdi-check-circle') + } catch (error) { + console.error('Error creating QR code:', error) + showSnackbar('Error', 'Gagal membuat QR Code. Silakan coba lagi', 'error', 'mdi-alert') + } + } else { + showSnackbar('Error', 'Container QR Code tidak ditemukan', 'error', 'mdi-alert') + } + } + + // Download QR Code + 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 fileName = `QR-Test-${generatePatientId.value}-${generateStatus.value}-${Date.now()}.png` + link.download = fileName + link.href = url + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + showSnackbar('Berhasil!', `QR Code berhasil didownload: ${fileName}`, 'success', 'mdi-download') + } catch (error) { + console.error('Download error:', error) + // Fallback: use img src directly + const link = document.createElement('a') + link.download = `QR-Test-${generatePatientId.value}-${generateStatus.value}.png` + link.href = img.src + link.click() + showSnackbar('Berhasil!', 'QR Code berhasil didownload', 'success', 'mdi-download') + } + } else { + showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert') + } + } + + // Copy QR Code to Clipboard + 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') + } + } + + // Share QR Code + 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 file = new File([blob], `QR-Test-${generatePatientId.value}-${generateStatus.value}.png`, { type: 'image/png' }) + + if (navigator.share && navigator.canShare({ files: [file] })) { + try { + await navigator.share({ + files: [file], + title: 'QR Code Check-in Test', + text: `QR Code untuk testing: ${generatedQRData.value}` + }) + showSnackbar('Berhasil!', 'QR Code berhasil dibagikan', 'success', 'mdi-share') + } catch (err: any) { + if (err.name !== 'AbortError') { + // Fallback to copy or download + copyQRToClipboard() + } + } + } else { + // Fallback: try copy to clipboard + copyQRToClipboard() + } + } catch (error) { + console.error('Share error:', error) + showSnackbar('Error', 'Gagal membagikan QR Code', 'error', 'mdi-alert') + } + } else { + showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert') + } + } + + // Computed untuk v-model + const generatePatientIdModel = computed({ + get: () => generatePatientId.value, + set: (value: string) => { + generatePatientId.value = value + } + }) + + const generateStatusModel = computed({ + get: () => generateStatus.value, + set: (value: CheckInStatus) => { + generateStatus.value = value + } + }) + + return { + generatePatientId: generatePatientIdModel, + generateStatus: generateStatusModel, + generatedQRData: readonly(generatedQRData), + generateRandomId, + generateQuickQR, + generateQRCode, + downloadQR, + copyQRToClipboard, + shareQR, + } +} diff --git a/composables/useQRScanner.ts b/composables/useQRScanner.ts new file mode 100644 index 0000000..1a3a686 --- /dev/null +++ b/composables/useQRScanner.ts @@ -0,0 +1,574 @@ +import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' +import { QR_CODE_ID, SCAN_DEBOUNCE_MS, SUCCESSFUL_SCANS_KEY } from '~/constants/checkin' + +export const useQRScanner = ( + onQRDetected: (decodedText: string) => void, + showSnackbar: (title: string, message: string, color: string, icon: string) => void +) => { + // 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_CODE_ID + let lastScannedQR: string | null = null + let lastScanTime: number = 0 + let isProcessing = false // Flag untuk mencegah pemrosesan berulang + + // Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian) + const successfulScans = ref>(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) + } + + // 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) + } + + // 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: 640 }, + height: { ideal: 480 } + } + }) + + 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') + } + } + + // Handle QR scan success + const handleQRScanSuccess = (decodedText: string) => { + // Validasi data QR - cek di awal sebelum log apapun + if (!decodedText || decodedText.trim() === '') { + return // Silent return untuk empty QR code + } + + // Cegah pemrosesan berulang jika sedang memproses + if (isProcessing) { + return // Silent return untuk mencegah spam log + } + + // Debounce: cegah scan berulang dalam waktu singkat - cek sebelum log + const now = Date.now() + if (lastScannedQR === decodedText && (now - lastScanTime) < SCAN_DEBOUNCE_MS) { + return // Silent return untuk debounce + } + + // Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian) + if (isQRCodeAlreadyScanned(decodedText)) { + console.log('ā­ļø Scan diabaikan: QR code sudah pernah berhasil di-scan') + showSnackbar('Peringatan', 'QR Code ini sudah pernah berhasil di-scan. Tidak dapat digunakan lagi untuk mencegah double antrian.', 'warning', 'mdi-alert-circle') + return + } + + // Set flag processing dan update last scan info + isProcessing = true + lastScannedQR = decodedText + lastScanTime = now + + console.log('šŸŽÆ QR Scan Success! Data:', decodedText) + + // Scanner tetap berjalan untuk memungkinkan scan QR code berikutnya + + // Proses data QR melalui callback + try { + onQRDetected(decodedText) + } catch (error) { + console.error('Error processing QR data:', error) + showSnackbar('Error', 'Gagal memproses data QR Code', 'error', 'mdi-alert') + } finally { + // Reset flag setelah selesai memproses (dengan delay kecil untuk memastikan callback selesai) + setTimeout(() => { + isProcessing = false + }, SCAN_DEBOUNCE_MS) + } + } + + // 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, 100)) + + // 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 + } + + // 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 lebih fleksibel + const baseVideoConstraints = isMobile.value + ? { + facingMode: "environment", + width: { ideal: 640, min: 320 }, + height: { ideal: 480, min: 240 } + } + : { + facingMode: "user", + width: { ideal: 1280, min: 640 }, + height: { ideal: 720, min: 480 } + } + + // Tambahkan advanced constraints hanya jika didukung + const videoConstraints: any = { ...baseVideoConstraints } + + console.log('Starting QR scanner with config:', { + isMobile: isMobile.value, + cameraConfig, + videoConstraints + }) + + try { + await html5QrCode.start( + cameraConfig, + { + fps: isMobile.value ? 15 : 20, // FPS optimal untuk deteksi + qrbox: function(viewfinderWidth: number, viewfinderHeight: number) { + // QR box dengan ukuran lebih besar untuk deteksi lebih mudah + const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight) + // Gunakan 80-90% dari viewfinder untuk area scanning yang lebih besar + const percentageSize = Math.floor(minEdgeSize * 0.80) + // Atau ukuran tetap yang lebih besar + const fixedSize = isMobile.value ? 250 : 250 + // Gunakan yang lebih besar antara percentage atau fixed + const qrboxSize = Math.max(percentageSize, fixedSize) + + // Pastikan tidak melebihi viewfinder + const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80) + + console.log('QR Box size:', finalSize, 'Viewfinder:', viewfinderWidth, 'x', viewfinderHeight) + + return { + width: Math.max(finalSize, 250), // Minimum 250px untuk deteksi lebih baik + height: Math.max(finalSize, 250) + } + }, + aspectRatio: 1.0, + disableFlip: false, + videoConstraints: videoConstraints, + rememberLastUsedCamera: true, + showTorchButtonIfSupported: true, + // Tambahkan opsi untuk meningkatkan deteksi + verbose: false // Set true untuk debugging + }, + (decodedText: string, decodedResult: any) => { + // QR Code berhasil di-scan + console.log('āœ… QR Code detected:', decodedText) + console.log('šŸ“Š Decoded result:', decodedResult) + + // Validasi dan proses QR code + if (decodedText && decodedText.trim() !== '') { + handleQRScanSuccess(decodedText) + } else { + console.warn('Empty QR code detected') + } + }, + (errorMessage: string) => { + // Log error untuk debugging - hanya log error penting + // Error ini biasanya muncul terus menerus saat tidak ada QR code yang terdeteksi + // Jadi kita filter untuk menghindari spam console + if (errorMessage) { + // Filter out common non-critical errors + const isCriticalError = !errorMessage.includes('NotFoundException') && + !errorMessage.includes('No QR') && + !errorMessage.includes('QR code parse error') && + !errorMessage.includes('QR code parse error, error') && + !errorMessage.includes('QR code decode error') + + if (isCriticalError) { + console.warn('QR Scanner warning:', errorMessage) + } + } + } + ) + + // Set camera ready setelah sedikit delay untuk memastikan video sudah dimuat + setTimeout(() => { + cameraReady.value = true + console.log('Camera ready, scanner is active') + }, 500) + + showSnackbar('Info', 'Scanner aktif. Arahkan kamera ke QR code dan pastikan pencahayaan cukup', 'info', 'mdi-camera') + } catch (cameraError: any) { + console.error('Camera error:', cameraError) + + // Jika kamera belakang tidak tersedia di mobile, coba kamera depan + if (isMobile.value && cameraError.name === 'NotFoundError') { + try { + console.log('Trying front camera as fallback...') + await html5QrCode.start( + { facingMode: "user" }, + { + fps: 15, + 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: 640, min: 320 }, + height: { ideal: 480, min: 240 } + }, + 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: isMobile.value ? 15 : 20, + 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 ? 640 : 1280, min: isMobile.value ? 320 : 640 }, + height: { ideal: isMobile.value ? 480 : 720, min: isMobile.value ? 240 : 480 } + }, + 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 () => { + const scannerInstance = html5QrCode + + // Reset state immediately to update UI + isScanning.value = false + cameraReady.value = false + + // Clear reference immediately to prevent re-entry + html5QrCode = null + + // If no instance, nothing to clean up + if (!scannerInstance) { + return + } + + try { + // Stop the scanner (this stops the camera stream) + if (typeof scannerInstance.stop === 'function') { + await scannerInstance.stop().catch((err: any) => { + // Ignore stop errors - scanner might already be stopped + console.warn('Scanner stop error (ignored):', err?.message || err) + }) + } + } catch (err: any) { + // Ignore errors from stop + console.warn('Error stopping scanner (ignored):', err?.message || err) + } + + try { + // Clear the scanner (this cleans up DOM and resources) + // Only try to clear if stop was successful or if clear method exists + if (typeof scannerInstance.clear === 'function') { + await scannerInstance.clear().catch((err: any) => { + // Ignore clear errors - DOM might already be cleaned up + console.warn('Scanner clear error (ignored):', err?.message || err) + }) + } + } catch (err: any) { + // Ignore errors from clear + console.warn('Error clearing scanner (ignored):', err?.message || err) + } + + // Show notification after cleanup attempts + try { + showSnackbar('Info', 'Scanner dihentikan', 'info', 'mdi-camera-off') + } catch (e) { + // Ignore snackbar errors + } + } + + // Initialize on mount + if (typeof window !== 'undefined') { + loadSuccessfulScans() + } + + return { + // State + isScanning, + hasCamera, + cameraChecking, + cameraReady, + // Functions + checkCameraAvailability, + testCamera, + startScanning, + stopScanning, + saveSuccessfulScan, + isQRCodeAlreadyScanned, + } +} diff --git a/composables/useSnackbar.ts b/composables/useSnackbar.ts new file mode 100644 index 0000000..5e38346 --- /dev/null +++ b/composables/useSnackbar.ts @@ -0,0 +1,44 @@ +import { ref, computed, readonly } from 'vue' +import type { SnackbarState } from '~/types/checkin' + +export const useSnackbar = () => { + const snackbar = ref({ + show: false, + title: '', + message: '', + color: '', + icon: '', + timeout: 4000, + }) + + const showSnackbar = ( + title: string, + message: string, + color: string, + icon: string, + timeout = 4000 + ) => { + snackbar.value = { + show: true, + title, + message, + color, + icon, + timeout, + } + } + + // Computed untuk snackbar.show agar v-model bisa bekerja + const snackbarShow = computed({ + get: () => snackbar.value.show, + set: (value: boolean) => { + snackbar.value.show = value + } + }) + + return { + snackbar: readonly(snackbar), + snackbarShow, + showSnackbar, + } +} diff --git a/constants/checkin.ts b/constants/checkin.ts new file mode 100644 index 0000000..1bad408 --- /dev/null +++ b/constants/checkin.ts @@ -0,0 +1,23 @@ +export const PRIMARY_COLOR = '#1565C0' +export const SECONDARY_COLOR = '#FB8C00' + +export const QR_CODE_ID = 'qr-reader' +export const SCAN_DEBOUNCE_MS = 2000 // 2 detik debounce untuk mencegah scan berulang + +export const HISTORY_STORAGE_KEY = 'checkin_history' +export const SCANNED_QR_STORAGE_KEY = 'scanned_qr_data' +export const SUCCESSFUL_SCANS_KEY = 'successful_qr_scans' + +export const MAX_HISTORY_ITEMS = 100 +export const MAX_SCANNED_QR_ITEMS = 50 + +export const HISTORY_STATUS_OPTIONS = [ + { title: 'Berhasil', value: 'success' }, + { title: 'Gagal', value: 'failed' }, + { title: 'Pending', value: 'pending' } +] as const + +export const QR_STATUS_OPTIONS = [ + { title: 'Diizinkan Check-in', value: 'ALLOWED' }, + { title: 'Belum Diizinkan', value: 'NOT_ALLOWED' } +] as const diff --git a/nuxt.config.ts b/nuxt.config.ts index 79f249b..d4c73c0 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -9,6 +9,17 @@ export default defineNuxtConfig({ }, }, + app: { + head: { + meta: [ + { name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' }, + { name: 'mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' } + ] + } + }, + modules: [ // "@nuxt/content", "@nuxt/eslint", @@ -21,10 +32,28 @@ export default defineNuxtConfig({ "@pinia/nuxt", "@vesp/nuxt-fontawesome", "@nuxtjs/google-fonts", - (_options, nuxt) => { - nuxt.hooks.hook("vite:extendConfig", (config) => { + async (_options, nuxt) => { + nuxt.hooks.hook("vite:extendConfig", async (config) => { // @ts-expect-error config.plugins.push(vuetify({ autoImport: true })); + + // Add HTTPS plugin + try { + // @ts-ignore + const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl'); + // @ts-expect-error + config.plugins.push(basicSsl()); + // @ts-expect-error + config.server = config.server || {}; + // @ts-expect-error + config.server.https = true; + // @ts-expect-error + config.server.host = '10.10.150.175'; + // @ts-expect-error + config.server.port = 3001; + } catch (e) { + console.warn('Failed to load HTTPS plugin:', e); + } }); }, ], @@ -64,19 +93,16 @@ export default defineNuxtConfig({ "@mdi/font/css/materialdesignicons.min.css", "~/assets/scss/main.scss", ], - devServer: { - host: "http://10.10.150.175", // Changed from "10.10.123.139" - port: 3001 - }, - // // 'http://10.10.150.114:3001/' - // "http://10.10.150.175:3001" + devServer: { + port: 3001, + host: '10.10.150.175' + }, vite: { css: { preprocessorOptions: { scss: { - api: "modern-compiler", additionalData: ` @use "sass:math"; @use "sass:map"; diff --git a/package-lock.json b/package-lock.json index bb62b8e..ccecb48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "chart.js": "^4.5.0", "dayjs": "^1.11.18", "eslint": "^9.32.0", + "html5-qrcode": "^2.3.8", "nuxt": "^3.17.7", "nuxt-qrcode": "^0.4.8", "pinia": "^3.0.3", @@ -10489,6 +10490,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", diff --git a/package.json b/package.json index 05be73c..caffa43 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "_command_dev2": "nuxt dev -o --port 3001", "_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001", "dev": "nuxt dev -o", + "dev:https": "nuxt dev -o --host localhost --port 3001", "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare" @@ -30,6 +31,7 @@ "chart.js": "^4.5.0", "dayjs": "^1.11.18", "eslint": "^9.32.0", + "html5-qrcode": "^2.3.8", "nuxt": "^3.17.7", "nuxt-qrcode": "^0.4.8", "pinia": "^3.0.3", @@ -44,6 +46,7 @@ "devDependencies": { "@nuxtjs/google-fonts": "^3.2.0", "@types/node": "^24.7.2", + "@vitejs/plugin-basic-ssl": "^2.1.0", "sass": "^1.93.3", "sass-embedded": "^1.89.2", "vite-plugin-vuetify": "^2.1.2", diff --git a/pages/CheckInPasien/checkIn.vue b/pages/CheckInPasien/checkIn.vue index d514a34..4566a05 100644 --- a/pages/CheckInPasien/checkIn.vue +++ b/pages/CheckInPasien/checkIn.vue @@ -1,1481 +1,3123 @@ - - + + \ No newline at end of file + + .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; + } + + .qr-reader-wrapper :deep(canvas) { + display: none !important; + } + + .qr-reader-wrapper :deep(#qr-reader__dashboard) { + display: none !important; + } + + .qr-reader-wrapper :deep(#qr-reader__scan_region) { + border-radius: 16px; + border: 3px solid rgba(21, 101, 192, 0.8) !important; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4) !important; + position: relative; + } + + .qr-reader-wrapper :deep(#qr-reader__scan_region::before) { + content: ''; + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + border: 3px solid rgba(21, 101, 192, 0.8); + border-radius: 16px; + animation: pulse-border 2s ease-in-out infinite; + } + + @keyframes pulse-border { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.02); + } + } + + .qr-reader-wrapper :deep(#qr-reader__scan_region video) { + border-radius: 16px; + width: 100% !important; + height: 100% !important; + aspect-ratio: 1 / 1; + object-fit: cover; + } + + .scanner-status { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + font-size: 12px; + } + + .scanner-instruction { + display: flex; + align-items: center; + justify-content: center; + margin-top: 12px; + padding: 10px 12px; + background: linear-gradient(135deg, rgba(21, 101, 192, 0.1) 0%, rgba(13, 71, 161, 0.1) 100%); + border-radius: 10px; + color: #1565C0; + font-weight: 500; + font-size: 12px; + text-align: center; + border: 1px solid rgba(21, 101, 192, 0.2); + } + + .scanner-loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + border-radius: 16px; + z-index: 10; + } + + .scanner-overlay { + position: absolute; + width: 80%; + height: 80%; + } + + .corner { + position: absolute; + width: 40px; + height: 40px; + border: 3px solid #1565C0; + } + + .corner-tl { + top: 0; + left: 0; + border-right: none; + border-bottom: none; + border-radius: 8px 0 0 0; + } + + .corner-tr { + top: 0; + right: 0; + border-left: none; + border-bottom: none; + border-radius: 0 8px 0 0; + } + + .corner-bl { + bottom: 0; + left: 0; + border-right: none; + border-top: none; + border-radius: 0 0 0 8px; + } + + .corner-br { + bottom: 0; + right: 0; + border-left: none; + border-top: none; + border-radius: 0 0 8px 0; + } + + .scan-line { + position: absolute; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, #1565C0, transparent); + top: 0; + animation: scan 2s linear infinite; + box-shadow: 0 0 10px #1565C0; + } + + @keyframes scan { + 0% { top: 0; } + 100% { top: 100%; } + } + + .qr-icon { + position: relative; + z-index: 1; + animation: breathe 2s ease-in-out infinite; + } + + @keyframes breathe { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } + } + + /* Modern Buttons */ + .btn-primary-modern { + background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%) !important; + color: white !important; + font-weight: 600; + text-transform: none; + letter-spacing: 0.3px; + border-radius: 12px !important; + padding: 14px 28px !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 16px rgba(21, 101, 192, 0.3) !important; + } + + .btn-primary-modern:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(21, 101, 192, 0.4) !important; + } + + .btn-primary-modern:active { + transform: translateY(0); + } + + .btn-primary-modern:disabled { + opacity: 0.5; + transform: none !important; + } + + .btn-stop-modern { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important; + color: white !important; + font-weight: 600; + text-transform: none; + letter-spacing: 0.3px; + border-radius: 12px !important; + padding: 14px 28px !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3) !important; + } + + .btn-stop-modern:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4) !important; + } + + .action-buttons { + margin-top: 20px; + display: flex; + justify-content: center; + } + + .btn-centered { + width: auto !important; + min-width: 240px !important; + max-width: 320px !important; + margin: 0 auto !important; + display: block !important; + } + + .btn-centered-small { + width: auto !important; + min-width: 180px !important; + margin: 0 auto; + display: block; + } + + .btn-test-camera { + border-color: #1565C0 !important; + color: #1565C0 !important; + } + + .btn-test-camera :deep(.v-btn__content) { + color: #1565C0 !important; + } + + .btn-test-camera :deep(.v-icon) { + color: #1565C0 !important; + opacity: 1 !important; + } + + /* Modern Inputs */ + .input-modern :deep(.v-field) { + border-radius: 12px; + font-size: 15px; + background: #fafafa; + border: 1.5px solid #e5e7eb; + transition: all 0.3s ease; + } + + .input-modern :deep(.v-field--focused) { + background: white; + border-color: #1565C0; + box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.1); + } + + .input-modern :deep(.v-field__input) { + padding-top: 12px; + padding-bottom: 12px; + } + + .input-modern :deep(.v-label) { + font-weight: 500; + color: #6b7280; + } + + .quick-actions { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e5e7eb; + } + + .info-card { + display: flex; + justify-content: center; + } + + .info-alert-centered { + width: 100%; + } + + .info-alert-centered :deep(.v-alert__content) { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + } + + .quick-actions .v-btn { + transition: all 0.3s ease; + border-radius: 12px !important; + border: 1.5px solid #e5e7eb !important; + } + + .quick-actions .v-btn:hover { + transform: translateY(-2px); + border-color: #1565C0 !important; + box-shadow: 0 4px 12px rgba(21, 101, 192, 0.15); + } + + .quick-actions .v-btn :deep(.v-icon) { + opacity: 1 !important; + color: inherit !important; + } + + .quick-actions .v-btn[color="primary"] { + color: #1565C0 !important; + border-color: #1565C0 !important; + } + + .quick-actions .v-btn[color="primary"] :deep(.v-icon) { + color: #1565C0 !important; + } + + .qr-code-container { + display: flex; + justify-content: center; + align-items: center; + padding: 16px; + background: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + + .qr-code-container :deep(canvas) { + border-radius: 8px; + } + + .qr-display { + animation: slideUp 0.5s ease-out; + } + + .blur-dialog :deep(.v-overlay__scrim) { + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + + .dialog-card { + overflow: hidden; + animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + + @keyframes popIn { + 0% { + opacity: 0; + transform: scale(0.8) translateY(20px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } + } + + .dialog-header { + position: relative; + overflow: hidden; + } + + .dialog-header::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%); + animation: rotate 10s linear infinite; + } + + @keyframes rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .icon-wrapper { + display: inline-block; + animation: bounceIn 0.6s ease-out 0.2s both; + } + + @keyframes bounceIn { + 0% { + opacity: 0; + transform: scale(0.3); + } + 50% { + transform: scale(1.05); + } + 70% { + transform: scale(0.9); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + .dialog-icon { + filter: drop-shadow(0 4px 12px rgba(0,0,0,0.3)); + } + + .success-header { + background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%); + } + + .warning-header { + background: linear-gradient(135deg, #FFA726 0%, #FB8C00 100%); + } + + .error-header { + background: linear-gradient(135deg, #EF5350 0%, #E53935 100%); + } + + .icon-bg-error { + background: linear-gradient(135deg, #EF5350 0%, #E53935 100%); + } + + .dialog-button { + text-transform: none; + letter-spacing: 0.5px; + font-weight: 600; + transition: all 0.3s ease; + } + + .dialog-button:hover { + transform: translateY(-2px); + } + + /* Patient Info Card Styling */ + .patient-info-card { + background: rgba(250, 250, 250, 0.8) !important; + border-color: rgba(0, 0, 0, 0.08) !important; + } + + .patient-info-label { + color: #9e9e9e !important; + font-weight: 500; + opacity: 0.9; + } + + .patient-info-value { + color: #757575 !important; + font-weight: 500 !important; + opacity: 0.85; + } + + .patient-info-icon { + opacity: 0.7; + } + + /* Stats Footer Modern */ + .stats-footer-modern { + animation: slideUp 0.5s ease-out 0.3s both; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .stat-card-modern { + background: white; + border-radius: 12px; + padding: 16px 12px; + text-align: center; + border: 1px solid #e5e7eb; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + } + + .stat-card-modern::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%); + transform: scaleX(0); + transition: transform 0.3s ease; + } + + .stat-card-modern:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + border-color: #1565C0; + } + + .stat-card-modern:hover::before { + transform: scaleX(1); + } + + .stat-icon-modern { + margin-bottom: 12px; + display: inline-flex; + padding: 8px; + background: #e3f2fd; + border-radius: 10px; + } + + .stat-value { + font-size: 20px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 4px; + letter-spacing: -0.5px; + } + + .stat-label { + font-size: 11px; + color: #6b7280; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .custom-snackbar { + margin-bottom: 16px; + margin-right: 16px; + } + + .custom-snackbar :deep(.v-snackbar__wrapper) { + min-width: 300px; + } + + /* History Dialog Styles */ + .history-list { + max-height: 500px; + overflow-y: auto; + padding-right: 8px; + } + + .history-list::-webkit-scrollbar { + width: 6px; + } + + .history-list::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; + } + + .history-list::-webkit-scrollbar-thumb { + background: #888; + border-radius: 10px; + } + + .history-list::-webkit-scrollbar-thumb:hover { + background: #555; + } + + .history-item { + transition: all 0.3s ease; + border-left: 4px solid transparent; + } + + .history-item:hover { + transform: translateX(4px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + + .history-success { + border-left-color: #4caf50; + background: rgba(76, 175, 80, 0.05); + } + + .history-failed { + border-left-color: #f44336; + background: rgba(244, 67, 54, 0.05); + } + + .history-pending { + border-left-color: #ff9800; + background: rgba(255, 152, 0, 0.05); + } + + /* Mobile Optimization */ + @media (max-width: 600px) { + .v-container.no-scroll-container { + padding: 8px !important; + } + + .main-card { + border-radius: 16px !important; + } + + .header-modern { + padding: 16px 16px 12px !important; + } + + .content-modern { + padding: 16px !important; + max-height: calc(100vh - 180px); + } + + .header-content { + flex-direction: column; + text-align: center; + gap: 12px; + } + + .header-text { + text-align: center; + } + + .icon-circle { + width: 56px; + height: 56px; + } + + .title-modern { + font-size: 20px; + } + + .subtitle-modern { + font-size: 13px; + } + + .content-modern { + padding: 20px 16px !important; + } + + .status-header { + padding: 16px; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 12px; + } + + .status-icon-wrapper { + margin: 0 auto; + } + + .status-text { + text-align: center; + width: 100%; + } + + .status-title { + font-size: 16px; + } + + .status-subtitle { + font-size: 13px; + } + + .qr-placeholder { + max-width: 100%; + height: 280px; + } + + .qr-reader-container { + max-width: 100%; + } + + .qr-reader-wrapper { + min-height: 300px; + max-height: 70vh; + border-radius: 16px; + } + + .qr-reader-wrapper :deep(video) { + max-height: 70vh; + object-fit: contain; + } + + .scanner-instruction { + font-size: 12px; + padding: 12px; + } + + .stats-footer-modern { + margin-top: 8px; + } + + .stat-card-modern { + padding: 12px 8px; + } + + .stat-value { + font-size: 18px; + } + + .stat-label { + font-size: 10px; + } + + .stat-icon-modern { + margin-bottom: 8px; + padding: 6px; + } + + .tabs-modern :deep(.v-tab) { + font-size: 12px; + padding: 0 12px; + } + + .tabs-modern :deep(.v-tab .v-icon) { + font-size: 18px; + } + + .btn-primary-modern, + .btn-stop-modern { + padding: 12px 24px !important; + font-size: 14px; + min-width: 200px !important; + } + + .btn-centered { + min-width: 200px !important; + max-width: 280px !important; + } + + .qr-placeholder { + max-width: 100%; + height: 240px; + } + + .qr-reader-wrapper { + min-height: 240px; + max-height: 280px; + } + } + + /* Mobile Landscape */ + @media (max-width: 900px) and (orientation: landscape) { + .qr-reader-wrapper { + min-height: 50vh; + max-height: 60vh; + } + + .qr-reader-wrapper :deep(video) { + max-height: 60vh; + } + } + \ No newline at end of file diff --git a/pages/CheckInPasien/checkIn.vue.refactore.backup b/pages/CheckInPasien/checkIn.vue.refactore.backup new file mode 100644 index 0000000..17fb045 --- /dev/null +++ b/pages/CheckInPasien/checkIn.vue.refactore.backup @@ -0,0 +1,717 @@ + + + + + \ No newline at end of file diff --git a/pages/Setting/HakAkses.vue b/pages/Setting/HakAkses.vue index eb0e392..04f6bcb 100644 --- a/pages/Setting/HakAkses.vue +++ b/pages/Setting/HakAkses.vue @@ -1,18 +1,49 @@ - \ No newline at end of file diff --git a/pages/Setting/UserLogin.vue b/pages/Setting/UserLogin.vue index e331798..55f55de 100644 --- a/pages/Setting/UserLogin.vue +++ b/pages/Setting/UserLogin.vue @@ -10,7 +10,7 @@ -