Compare commits
30
Commits
main
..
0a2453bdab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2453bdab | ||
|
|
b5e40c68d2 | ||
|
|
c95da96017 | ||
|
|
dfcd59481c | ||
|
|
d2a51f3aee | ||
|
|
78de0418e1 | ||
|
|
4c2c68d993 | ||
|
|
10ef054de6 | ||
|
|
9b3fd7b314 | ||
|
|
817bf8f548 | ||
|
|
82b98fdfe6 | ||
|
|
642be0f9df | ||
|
|
e2cf3d309a | ||
|
|
89688e02f3 | ||
|
|
56fb6b319b | ||
|
|
39d064c30c | ||
|
|
dbb8050dc5 | ||
|
|
4960d4659f | ||
|
|
11d2a4671e | ||
|
|
35024b4797 | ||
|
|
d7f3240a82 | ||
|
|
71890ffb4c | ||
|
|
31e18c5965 | ||
|
|
0351f9116d | ||
|
|
51809468a9 | ||
|
|
9261df87e0 | ||
|
|
d735824d57 | ||
|
|
55d8920a82 | ||
|
|
81398549e2 | ||
|
|
0ad29ae5fa |
No files matched your search
@@ -6,6 +6,12 @@
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Database files
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
@@ -22,3 +28,6 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
REFACTORING_STATUS.md
|
||||
CHECKIN_DOCUMENTATION.md
|
||||
USERLOGIN_DOCUMENTATION.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 <SQL> - 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`
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Pages List - Web Antrean Application
|
||||
|
||||
This document lists all pages in the application with their file paths and route paths.
|
||||
|
||||
## Root Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Home/Index | `pages/index.vue` | `/` |
|
||||
| Login Page | `pages/LoginPage.vue` | `/login-page` |
|
||||
| Dashboard | `pages/Dashboard.vue` | `/dashboard` |
|
||||
| Admin Klinik | `pages/AdminKlinik.vue` | `/admin-klinik` |
|
||||
| Admin Loket | `pages/AdminLoket.vue` | `/admin-loket` |
|
||||
| Admin Penunjang | `pages/AdminPenunjang.vue` | `/admin-penunjang` |
|
||||
| Buat Antrean | `pages/BuatAntrean.vue` | `/buat-antrean` |
|
||||
| Klinik Ruang Admin | `pages/KlinikRuangAdmin.vue` | `/klinik-ruang-admin` |
|
||||
| Ranap Admin | `pages/RanapAdmin.vue` | `/ranap-admin` |
|
||||
|
||||
## Anjungan Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Anjungan | `pages/Anjungan/Anjungan.vue` | `/anjungan` |
|
||||
| Admin Anjungan | `pages/Anjungan/AdminAnjungan.vue` | `/anjungan/admin-anjungan` |
|
||||
| Antrian Klinik | `pages/Anjungan/AntrianKlinik.vue` | `/anjungan/antrian-klinik` |
|
||||
| Antrian Klinik Ruang | `pages/Anjungan/AntrianKlinikRuang.vue` | `/anjungan/antrian-klinik-ruang` |
|
||||
| Antrian Penunjang | `pages/Anjungan/AntrianPenunjang.vue` | `/anjungan/antrian-penunjang` |
|
||||
|
||||
## Check In Pasien Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Check In | `pages/CheckInPasien/checkIn.vue` | `/check-in-pasien/check-in` |
|
||||
|
||||
## Data Pasien Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Data Pasien Index | `pages/data-pasien/index.vue` | `/data-pasien` |
|
||||
| Edit Data Pasien | `pages/data-pasien/edit/[id].vue` | `/data-pasien/edit/:id` |
|
||||
|
||||
## Monitoring Pasien Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Monitoring Pasien | `pages/MonitoringPasien/monitoringPasien.vue` | `/monitoring-pasien/monitoring-pasien` |
|
||||
| Detail Pasien | `pages/MonitoringPasien/pasien/[id].vue` | `/monitoring-pasien/pasien/:id` |
|
||||
|
||||
## Profile Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Profil | `pages/Profile/Profil.vue` | `/profile/profil` |
|
||||
|
||||
|
||||
## Setting Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| User Login | `pages/Setting/UserLogin.vue` | `/setting/user-login` |
|
||||
| Hak Akses | `pages/Setting/HakAkses.vue` | `/setting/hak-akses` |
|
||||
| Master Klinik | `pages/Setting/MasterKlinik.vue` | `/setting/master-klinik` |
|
||||
| Master Klinik Ruang | `pages/Setting/MasterKlinikRuang.vue` | `/setting/master-klinik-ruang` |
|
||||
| Master Loket | `pages/Setting/MasterLoket.vue` | `/setting/master-loket` |
|
||||
| Master Penunjang | `pages/Setting/MasterPenunjang.vue` | `/setting/master-penunjang` |
|
||||
| Screen | `pages/Setting/Screen.vue` | `/setting/screen` |
|
||||
| Tambah Loket | `pages/Setting/TambahLoket.vue` | `/setting/tambah-loket` |
|
||||
| Edit Loket | `pages/Setting/Edit-Loket/[id].vue` | `/setting/edit-loket/:id` |
|
||||
| Screen Index | `pages/Setting/Screen/index.vue` | `/setting/screen` |
|
||||
| Edit Screen | `pages/Setting/Screen/edit/[id].vue` | `/setting/screen/edit/:id` |
|
||||
|
||||
## Verifikasi Akun Pages
|
||||
|
||||
| Page Name | File Path | Route Path |
|
||||
|-----------|-----------|------------|
|
||||
| Verifikasi Akun | `pages/verifikasiAkun/VerifikasiAkun.vue` | `/verifikasi-akun/verifikasi-akun` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total Pages**: 30+ pages
|
||||
- **Dynamic Routes**: 4 pages with `[id]` parameter
|
||||
- **Note**: `Pengaturan.vue.txt` appears to be a text file, not an active Vue component
|
||||
|
||||
## Route Path Conversion Rules
|
||||
|
||||
In Nuxt.js, file paths are converted to routes as follows:
|
||||
- File names are converted to kebab-case
|
||||
- Folders create nested routes
|
||||
- `index.vue` files create routes at the folder level
|
||||
- `[id].vue` files create dynamic routes with `:id` parameter
|
||||
- Special characters like hyphens are preserved
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Placeholder API Permission
|
||||
|
||||
## Deskripsi
|
||||
Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia.
|
||||
|
||||
## Cara Menggunakan
|
||||
|
||||
### 1. Menggunakan Placeholder API secara Default
|
||||
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.150.131:8089/api/v1/permission`) tidak dapat diakses.
|
||||
|
||||
### 2. Memaksa Menggunakan Placeholder API
|
||||
Tambahkan query parameter `usePlaceholder=true` pada request:
|
||||
```
|
||||
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true
|
||||
```
|
||||
|
||||
### 3. Menonaktifkan Placeholder API
|
||||
Tambahkan query parameter `usePlaceholder=false` pada request:
|
||||
```
|
||||
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
|
||||
```
|
||||
|
||||
## Data Placeholder yang Tersedia
|
||||
|
||||
### Role: superadmin, Group: STIM
|
||||
Data placeholder mengembalikan 5 permission items:
|
||||
- Halaman Utama (read: true, active: true)
|
||||
- Pengaturan (read: true, active: true)
|
||||
- Halaman (read: true, active: true, disable: true)
|
||||
- Dashboard (read: true, active: true, disable: true)
|
||||
|
||||
## Mapping Pagename ke Menu Sidebar
|
||||
|
||||
Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
|
||||
- "Halaman Utama" → "Dashboard"
|
||||
- "Pengaturan" → "Master Data"
|
||||
- "Halaman" → "Master Data"
|
||||
- "Dashboard" → "Dashboard"
|
||||
|
||||
## Testing dengan User bayurssa
|
||||
|
||||
Untuk testing dengan user email "bayurssa":
|
||||
1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak
|
||||
2. Login dengan email "bayurssa" dan password "12345"
|
||||
3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia
|
||||
4. Sidebar akan terfilter berdasarkan permissions dari placeholder API
|
||||
|
||||
## Mapping Role dan Group
|
||||
|
||||
Sistem secara otomatis melakukan normalisasi untuk role dan group:
|
||||
|
||||
### Normalisasi Role
|
||||
- `default-roles-sandbox` → `superadmin`
|
||||
- Role lain akan digunakan apa adanya (lowercase)
|
||||
|
||||
### Normalisasi Group
|
||||
- `Instalasi STIM` → `STIM`
|
||||
- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM"
|
||||
- Group lain akan digunakan apa adanya (uppercase)
|
||||
|
||||
### Contoh Mapping
|
||||
- Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
|
||||
- Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
|
||||
|
||||
## Catatan
|
||||
- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan
|
||||
- Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API
|
||||
- Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API
|
||||
- Jika backend API juga gagal, sistem akan mengembalikan data kosong
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
/* Neutral Colors */
|
||||
--neutral-900: #212121;
|
||||
--neutral-800: #4D4D4D;
|
||||
--neutral-700: #717171;
|
||||
--neutral-600: #89939E;
|
||||
--neutral-500: #ABBED1;
|
||||
--neutral-400: #E5F7FA;
|
||||
--neutral-300: #F5F7FA;
|
||||
--neutral-100: #FFFFFF;
|
||||
|
||||
/* Primary Colors (Orange) */
|
||||
--primary-700: #0053AD;
|
||||
--primary-600: #0663C7;
|
||||
--primary-500: #0671E0;
|
||||
--primary-400: #4196F0;
|
||||
--primary-300: #DBEDFF;
|
||||
--primary-200: #EEF5FC;
|
||||
--primary-100: #EEF5FC;
|
||||
|
||||
/* Secondary Colors (Blue) */
|
||||
--secondary-700: #0053AD;
|
||||
--secondary-600: #0671E0;
|
||||
--secondary-500: #0663C7;
|
||||
--secondary-400: #4196F0;
|
||||
--secondary-300: #DBEDFF;
|
||||
--secondary-200: #EEF5FC;
|
||||
--secondary-100: #0053AD;
|
||||
|
||||
/* Success Colors (Green) */
|
||||
--success-700: #1B6E53;
|
||||
--success-600: #009262;
|
||||
--success-500: #115B43;
|
||||
--success-400: #32C997;
|
||||
--success-300: #84DFC1;
|
||||
--success-200: #F1FBF8;
|
||||
|
||||
/* Danger Colors (Red) */
|
||||
--danger-700: #E01507;
|
||||
--danger-600: #E02B1D;
|
||||
--danger-500: #C33025;
|
||||
--danger-400: #FF5A4F;
|
||||
--danger-300: #F0857D;
|
||||
--danger-200: #FFF1F0;
|
||||
}
|
||||
|
||||
/* Utility Classes untuk Background */
|
||||
.bg-neutral-900 { background-color: var(--neutral-900); }
|
||||
.bg-neutral-800 { background-color: var(--neutral-800); }
|
||||
.bg-neutral-700 { background-color: var(--neutral-700); }
|
||||
.bg-neutral-600 { background-color: var(--neutral-600); }
|
||||
.bg-neutral-500 { background-color: var(--neutral-500); }
|
||||
.bg-neutral-400 { background-color: var(--neutral-400); }
|
||||
.bg-neutral-300 { background-color: var(--neutral-300); }
|
||||
.bg-neutral-100 { background-color: var(--neutral-100); }
|
||||
|
||||
.bg-primary-700 { background-color: var(--primary-700); }
|
||||
.bg-primary-600 { background-color: var(--primary-600); }
|
||||
.bg-primary-500 { background-color: var(--primary-500); }
|
||||
.bg-primary-400 { background-color: var(--primary-400); }
|
||||
.bg-primary-300 { background-color: var(--primary-300); }
|
||||
.bg-primary-200 { background-color: var(--primary-200); }
|
||||
.bg-primary-100 { background-color: var(--primary-100); }
|
||||
|
||||
.bg-secondary-700 { background-color: var(--secondary-700); }
|
||||
.bg-secondary-600 { background-color: var(--secondary-600); }
|
||||
.bg-secondary-500 { background-color: var(--secondary-500); }
|
||||
.bg-secondary-400 { background-color: var(--secondary-400); }
|
||||
.bg-secondary-300 { background-color: var(--secondary-300); }
|
||||
.bg-secondary-200 { background-color: var(--secondary-200); }
|
||||
|
||||
.bg-success-700 { background-color: var(--success-700); }
|
||||
.bg-success-600 { background-color: var(--success-600); }
|
||||
.bg-success-500 { background-color: var(--success-500); }
|
||||
.bg-success-400 { background-color: var(--success-400); }
|
||||
.bg-success-300 { background-color: var(--success-300); }
|
||||
.bg-success-200 { background-color: var(--success-200); }
|
||||
|
||||
.bg-danger-700 { background-color: var(--danger-700); }
|
||||
.bg-danger-600 { background-color: var(--danger-600); }
|
||||
.bg-danger-500 { background-color: var(--danger-500); }
|
||||
.bg-danger-400 { background-color: var(--danger-400); }
|
||||
.bg-danger-300 { background-color: var(--danger-300); }
|
||||
.bg-danger-200 { background-color: var(--danger-200); }
|
||||
|
||||
/* Utility Classes untuk Text Color */
|
||||
.text-neutral-900 { color: var(--neutral-900); }
|
||||
.text-neutral-800 { color: var(--neutral-800); }
|
||||
.text-neutral-700 { color: var(--neutral-700); }
|
||||
.text-neutral-600 { color: var(--neutral-600); }
|
||||
.text-neutral-500 { color: var(--neutral-500); }
|
||||
.text-neutral-100 { color: var(--neutral-100); }
|
||||
|
||||
.text-primary-700 { color: var(--primary-700); }
|
||||
.text-primary-600 { color: var(--primary-600); }
|
||||
.text-primary-500 { color: var(--primary-500); }
|
||||
.text-primary-400 { color: var(--primary-400); }
|
||||
|
||||
.text-secondary-700 { color: var(--secondary-700); }
|
||||
.text-secondary-600 { color: var(--secondary-600); }
|
||||
.text-secondary-500 { color: var(--secondary-500); }
|
||||
|
||||
.text-success-700 { color: var(--success-700); }
|
||||
.text-success-600 { color: var(--success-600); }
|
||||
.text-success-500 { color: var(--success-500); }
|
||||
|
||||
.text-danger-700 { color: var(--danger-700); }
|
||||
.text-danger-600 { color: var(--danger-600); }
|
||||
.text-danger-500 { color: var(--danger-500); }
|
||||
|
||||
/* Utility Classes untuk Border */
|
||||
.border-primary-500 { border-color: var(--primary-500); }
|
||||
.border-secondary-500 { border-color: var(--secondary-500); }
|
||||
.border-success-500 { border-color: var(--success-500); }
|
||||
.border-danger-500 { border-color: var(--danger-500); }
|
||||
.border-neutral-500 { border-color: var(--neutral-500); }
|
||||
@@ -0,0 +1,109 @@
|
||||
/* Base Font */
|
||||
* {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Headlines - Desktop */
|
||||
.headline-1 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 64px;
|
||||
line-height: 76px;
|
||||
}
|
||||
|
||||
.headline-2 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 36px;
|
||||
line-height: 44px;
|
||||
}
|
||||
|
||||
.headline-3 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.headline-4 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
/* Body - Desktop */
|
||||
.body-1 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400; /* Regular */
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.body-2 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400; /* Regular */
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400; /* Regular */
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Caption - Desktop */
|
||||
.text-1 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400; /* Regular */
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.text-2 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400; /* Regular */
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* Mobile Typography */
|
||||
@media (max-width: 768px) {
|
||||
.headline-1 {
|
||||
font-weight: 700; /* Bold */
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.headline-2 {
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.headline-3 {
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.headline-4 {
|
||||
font-weight: 600; /* Semi Bold */
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-weight: 500; /* Medium */
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-weight: 400; /* Regular */
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
@use 'sass:map';
|
||||
|
||||
$neutral-900: #212121;
|
||||
$neutral-800: #4D4D4D;
|
||||
$neutral-700: #717171;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-100: #FFFFFF;
|
||||
|
||||
$primary-700: #FF9B1B;
|
||||
$primary-600: #FFA532;
|
||||
$primary-500: #FFB95F;
|
||||
$primary-400: #FFCD8D;
|
||||
$primary-300: #FFDCAF;
|
||||
$primary-200: #FFE6C6;
|
||||
$primary-100: #EEF5FC;
|
||||
|
||||
$secondary-700: #0053AD;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-500: #0663C7;
|
||||
$secondary-400: #4196F0;
|
||||
$secondary-300: #DBEDFF;
|
||||
$secondary-200: #EEF5FC;
|
||||
|
||||
$success-700: #1B6E53;
|
||||
$success-600: #009262;
|
||||
$success-500: #115B43;
|
||||
$success-400: #32C997;
|
||||
$success-300: #84DFC1;
|
||||
$success-200: #F1FBF8;
|
||||
|
||||
$danger-700: #E01507;
|
||||
$danger-600: #E02B1D;
|
||||
$danger-500: #C33025;
|
||||
$danger-400: #FF5A4F;
|
||||
$danger-300: #F0857D;
|
||||
$danger-200: #FFF1F0;
|
||||
|
||||
// CSS Variables untuk dynamic theming
|
||||
:root {
|
||||
// Neutral
|
||||
--color-neutral-900: #{$neutral-900};
|
||||
--color-neutral-800: #{$neutral-800};
|
||||
--color-neutral-700: #{$neutral-700};
|
||||
--color-neutral-600: #{$neutral-600};
|
||||
--color-neutral-500: #{$neutral-500};
|
||||
--color-neutral-400: #{$neutral-400};
|
||||
--color-neutral-300: #{$neutral-300};
|
||||
--color-neutral-100: #{$neutral-100};
|
||||
|
||||
// Primary
|
||||
--color-primary-700: #{$primary-700};
|
||||
--color-primary-600: #{$primary-600};
|
||||
--color-primary-500: #{$primary-500};
|
||||
--color-primary-400: #{$primary-400};
|
||||
--color-primary-300: #{$primary-300};
|
||||
--color-primary-200: #{$primary-200};
|
||||
--color-primary-100: #{$primary-100};
|
||||
|
||||
// Secondary
|
||||
--color-secondary-700: #{$secondary-700};
|
||||
--color-secondary-600: #{$secondary-600};
|
||||
--color-secondary-500: #{$secondary-500};
|
||||
--color-secondary-400: #{$secondary-400};
|
||||
--color-secondary-300: #{$secondary-300};
|
||||
--color-secondary-200: #{$secondary-200};
|
||||
|
||||
// Success
|
||||
--color-success-700: #{$success-700};
|
||||
--color-success-600: #{$success-600};
|
||||
--color-success-500: #{$success-500};
|
||||
--color-success-400: #{$success-400};
|
||||
--color-success-300: #{$success-300};
|
||||
--color-success-200: #{$success-200};
|
||||
|
||||
// Danger
|
||||
--color-danger-700: #{$danger-700};
|
||||
--color-danger-600: #{$danger-600};
|
||||
--color-danger-500: #{$danger-500};
|
||||
--color-danger-400: #{$danger-400};
|
||||
--color-danger-300: #{$danger-300};
|
||||
--color-danger-200: #{$danger-200};
|
||||
|
||||
// Theme colors (akan di-override oleh theme system)
|
||||
--color-primary: #{$primary-500};
|
||||
--color-secondary: #{$secondary-500};
|
||||
--color-success: #{$success-600};
|
||||
--color-danger: #{$danger-600};
|
||||
--color-background: #{$neutral-300};
|
||||
--color-surface: #{$neutral-100};
|
||||
--color-textPrimary: #{$neutral-900};
|
||||
--color-textSecondary: #{$neutral-700};
|
||||
--color-borderColor: #{$neutral-500};
|
||||
}
|
||||
|
||||
// Dark mode base
|
||||
.dark {
|
||||
--color-background: #1a1a1a;
|
||||
--color-surface: #{$neutral-900};
|
||||
--color-textPrimary: #{$neutral-100};
|
||||
--color-textSecondary: #{$neutral-500};
|
||||
--color-borderColor: #{$neutral-800};
|
||||
}
|
||||
|
||||
// Mixin untuk generate color utilities
|
||||
@mixin color-utilities($prefix, $color) {
|
||||
.text-#{$prefix} {
|
||||
color: $color;
|
||||
}
|
||||
|
||||
.bg-#{$prefix} {
|
||||
background-color: $color;
|
||||
}
|
||||
|
||||
.border-#{$prefix} {
|
||||
border-color: $color;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate color utilities
|
||||
$colors: (
|
||||
'neutral-900': $neutral-900,
|
||||
'neutral-800': $neutral-800,
|
||||
'neutral-700': $neutral-700,
|
||||
'neutral-600': $neutral-600,
|
||||
'neutral-500': $neutral-500,
|
||||
'neutral-400': $neutral-400,
|
||||
'neutral-300': $neutral-300,
|
||||
'neutral-100': $neutral-100,
|
||||
'primary-700': $primary-700,
|
||||
'primary-600': $primary-600,
|
||||
'primary-500': $primary-500,
|
||||
'primary-400': $primary-400,
|
||||
'primary-300': $primary-300,
|
||||
'primary-200': $primary-200,
|
||||
'secondary-700': $secondary-700,
|
||||
'secondary-600': $secondary-600,
|
||||
'secondary-500': $secondary-500,
|
||||
'success-700': $success-700,
|
||||
'success-600': $success-600,
|
||||
'success-500': $success-500,
|
||||
'danger-700': $danger-700,
|
||||
'danger-600': $danger-600,
|
||||
'danger-500': $danger-500
|
||||
);
|
||||
|
||||
@each $name, $color in $colors {
|
||||
@include color-utilities($name, $color);
|
||||
}
|
||||
|
||||
// Shorthand semantic colors
|
||||
.text-primary { color: $primary-500; }
|
||||
.text-secondary { color: $secondary-500; }
|
||||
.text-success { color: $success-600; }
|
||||
.text-danger { color: $danger-600; }
|
||||
.text-muted { color: $neutral-600; }
|
||||
|
||||
.bg-primary { background-color: $primary-500; }
|
||||
.bg-secondary { background-color: $secondary-500; }
|
||||
.bg-success { background-color: $success-600; }
|
||||
.bg-danger { background-color: $danger-600; }
|
||||
.bg-light { background-color: $neutral-300; }
|
||||
.bg-white { background-color: $neutral-100; }
|
||||
@@ -0,0 +1,121 @@
|
||||
@use 'sass:map';
|
||||
@use './variables' as *;
|
||||
|
||||
// Mixin untuk generate typography classes
|
||||
@mixin typography($name, $size, $line-height, $weight) {
|
||||
.#{$name} {
|
||||
font-family: $font-family-base;
|
||||
font-size: $size;
|
||||
line-height: $line-height;
|
||||
font-weight: $weight;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Base typography
|
||||
* {
|
||||
font-family: $font-family-base;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: $font-family-base;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: $font-weight-regular;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Generate desktop typography classes - Gunakan map.get
|
||||
@each $name, $props in $font-sizes {
|
||||
@include typography(
|
||||
$name,
|
||||
map.get($props, 'size'),
|
||||
map.get($props, 'line-height'),
|
||||
map.get($props, 'weight')
|
||||
);
|
||||
}
|
||||
|
||||
// Generate mobile typography classes - Gunakan map.get
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
@each $name, $props in $font-sizes-mobile {
|
||||
@include typography(
|
||||
$name,
|
||||
map.get($props, 'size'),
|
||||
map.get($props, 'line-height'),
|
||||
map.get($props, 'weight')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HTML semantic tags
|
||||
h1 {
|
||||
@extend .headline-1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@extend .headline-2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@extend .headline-3;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@extend .headline-4;
|
||||
}
|
||||
|
||||
p {
|
||||
@extend .body-1;
|
||||
}
|
||||
|
||||
small {
|
||||
@extend .caption-2;
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.text-regular {
|
||||
font-weight: $font-weight-regular !important;
|
||||
}
|
||||
|
||||
.text-medium {
|
||||
font-weight: $font-weight-medium !important;
|
||||
}
|
||||
|
||||
.text-semibold {
|
||||
font-weight: $font-weight-semibold !important;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: $font-weight-bold !important;
|
||||
}
|
||||
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Line clamp utilities
|
||||
@for $i from 1 through 5 {
|
||||
.line-clamp-#{$i} {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: $i;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
@use 'sass:map';
|
||||
@use 'sass:math';
|
||||
|
||||
// Font Family
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
// Font Weights
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
// Font Sizes & Line Heights - Desktop
|
||||
$font-sizes: (
|
||||
'headline-1': (
|
||||
'size': 64px,
|
||||
'line-height': 76px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'headline-2': (
|
||||
'size': 36px,
|
||||
'line-height': 44px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'headline-3': (
|
||||
'size': 28px,
|
||||
'line-height': 36px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'headline-4': (
|
||||
'size': 20px,
|
||||
'line-height': 28px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'body-1': (
|
||||
'size': 18px,
|
||||
'line-height': 28px,
|
||||
'weight': $font-weight-regular
|
||||
),
|
||||
'body-2': (
|
||||
'size': 16px,
|
||||
'line-height': 24px,
|
||||
'weight': $font-weight-regular
|
||||
),
|
||||
'body-3': (
|
||||
'size': 14px,
|
||||
'line-height': 20px,
|
||||
'weight': $font-weight-regular
|
||||
),
|
||||
'caption-1': (
|
||||
'size': 16px,
|
||||
'line-height': 24px,
|
||||
'weight': $font-weight-regular
|
||||
),
|
||||
'caption-2': (
|
||||
'size': 12px,
|
||||
'line-height': 16px,
|
||||
'weight': $font-weight-regular
|
||||
)
|
||||
);
|
||||
|
||||
// Font Sizes - Mobile
|
||||
$font-sizes-mobile: (
|
||||
'headline-1': (
|
||||
'size': 28px,
|
||||
'line-height': 36px,
|
||||
'weight': $font-weight-bold
|
||||
),
|
||||
'headline-2': (
|
||||
'size': 20px,
|
||||
'line-height': 28px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'headline-3': (
|
||||
'size': 18px,
|
||||
'line-height': 24px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'headline-4': (
|
||||
'size': 18px,
|
||||
'line-height': 24px,
|
||||
'weight': $font-weight-semibold
|
||||
),
|
||||
'body': (
|
||||
'size': 16px,
|
||||
'line-height': 24px,
|
||||
'weight': $font-weight-medium
|
||||
),
|
||||
'caption': (
|
||||
'size': 14px,
|
||||
'line-height': 20px,
|
||||
'weight': $font-weight-regular
|
||||
)
|
||||
);
|
||||
|
||||
// Breakpoints
|
||||
$breakpoint-mobile: 768px;
|
||||
$breakpoint-tablet: 1024px;
|
||||
$breakpoint-desktop: 1280px;
|
||||
|
||||
// Mixins
|
||||
@mixin heading($level) {
|
||||
@if map.has-key($font-sizes, $level) {
|
||||
$props: map.get($font-sizes, $level);
|
||||
font-size: map.get($props, 'size');
|
||||
line-height: map.get($props, 'line-height');
|
||||
font-weight: map.get($props, 'weight');
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin responsive-text {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,71 @@
|
||||
// Gunakan @use daripada @import
|
||||
@use './variables' as *;
|
||||
@use './colors' as *;
|
||||
@use './typography' as *;
|
||||
|
||||
// Global styles
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-textPrimary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
// Selection color
|
||||
::selection {
|
||||
background-color: var(--color-primary-300);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
// Focus styles
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// Responsive images
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Links
|
||||
a {
|
||||
color: var(--color-primary-500);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons base
|
||||
button {
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/* assets/styles/loginpage/login.css */
|
||||
|
||||
/* --- COLOR DEFINITIONS --- */
|
||||
/* Base Orange: #FF9B1B (New) */
|
||||
/* Hover/Darker Orange: #E68A00 (Calculated darker shade for hover effect) */
|
||||
/* --- END COLOR DEFINITIONS --- */
|
||||
|
||||
|
||||
/* Main Background - Now Orange */
|
||||
.login-background {
|
||||
background: #FF9B1B; /* Solid Orange Background: #FF9B1B */
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background::before {
|
||||
/* No radial gradients */
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Floating Medical Icons - Subtle on Orange Background */
|
||||
.floating-medical-icon {
|
||||
position: absolute;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
top: auto;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.floating-medical-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floating-medical-icon .v-icon {
|
||||
color: rgba(255, 255, 255, 0.15) !important; /* Light white for icons on orange background */
|
||||
}
|
||||
|
||||
/* Specific icon positioning - now static */
|
||||
.icon-1 { top: 15%; left: 5%; }
|
||||
.icon-2 { top: 20%; right: 10%; }
|
||||
.icon-3 { bottom: 10%; left: 15%; }
|
||||
.icon-4 { top: 60%; left: 10%; }
|
||||
.icon-5 { bottom: 20%; right: 5%; }
|
||||
.icon-6 { top: 35%; left: 20%; }
|
||||
|
||||
/* Main Card - White */
|
||||
.main-card {
|
||||
z-index: 3;
|
||||
/* max-width is now set in template to 450px */
|
||||
margin: 2rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); /* Adding a subtle shadow back */
|
||||
}
|
||||
|
||||
.white-card {
|
||||
background: white !important; /* Main card background is white */
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
/* box-shadow is now managed by .main-card */
|
||||
}
|
||||
|
||||
/* Right Login Section (White) */
|
||||
.login-section-box {
|
||||
background: white; /* Changed to white */
|
||||
min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */
|
||||
}
|
||||
|
||||
.login-content {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* White Dialog Styles */
|
||||
.white-dialog {
|
||||
background: white !important;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Card Header - Dark Text for white background */
|
||||
.welcome-title-dark {
|
||||
color: #333; /* Dark text on white background */
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-instruction-dark {
|
||||
color: #777; /* Grey text for instruction on white background */
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* App Title - Dark for white background */
|
||||
.app-title-dark {
|
||||
color: #FF9B1B; /* Orange app title: #FF9B1B */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.hospital-logo {
|
||||
height: 332px;
|
||||
width: auto;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.sso-text-dark {
|
||||
color: #555; /* Dark grey for SSO text */
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons (Orange theme) */
|
||||
.login-btn {
|
||||
background: #FF9B1B !important; /* Solid Orange button: #FF9B1B */
|
||||
color: white !important; /* White text on orange button */
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 8px 25px rgba(255, 155, 27, 0.3), /* Orange shadow: #FF9B1B */
|
||||
0 4px 12px rgba(255, 155, 27, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
text-transform: none;
|
||||
font-size: 1rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
background: #E68A00 !important; /* Slightly darker orange on hover */
|
||||
box-shadow:
|
||||
0 12px 30px rgba(255, 155, 27, 0.4),
|
||||
0 6px 15px rgba(255, 155, 27, 0.3);
|
||||
}
|
||||
|
||||
.register-btn-dark {
|
||||
color: #FF9B1B !important; /* Orange text for register button: #FF9B1B */
|
||||
border: 2px solid #FF9B1B !important; /* Orange border: #FF9B1B */
|
||||
background: transparent !important;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: none;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.register-btn-dark:hover {
|
||||
background: rgba(255, 155, 27, 0.05) !important; /* Light orange hover background */
|
||||
border-color: #E68A00 !important; /* Darker orange border on hover */
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Custom Divider - Dark for white background */
|
||||
.custom-divider-dark {
|
||||
border-color: rgba(0, 0, 0, 0.1) !important; /* Light grey divider */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Help Text and Links - Dark for white background */
|
||||
.help-text-dark {
|
||||
color: #555; /* Dark grey help text */
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.contact-link-dark {
|
||||
color: #FF9B1B !important; /* Orange link: #FF9B1B */
|
||||
text-decoration: underline;
|
||||
text-transform: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-link-dark {
|
||||
color: #777; /* Grey link */
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-link-dark:hover {
|
||||
color: #FF9B1B; /* Orange on hover: #FF9B1B */
|
||||
}
|
||||
|
||||
/* Transparent Background */
|
||||
.transparent {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Dialog Accents */
|
||||
.v-card-title .v-icon,
|
||||
.v-list-item .v-icon {
|
||||
color: #FF9B1B !important; /* Orange accents: #FF9B1B */
|
||||
}
|
||||
|
||||
.v-btn[color="#FF9B1B"] {
|
||||
background-color: #FF9B1B !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.v-alert[color="orange"] {
|
||||
border-left: 8px solid #FF9B1B !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* Responsive Design Adjustments for single column */
|
||||
@media (max-width: 960px) {
|
||||
|
||||
.main-card {
|
||||
margin: 1rem;
|
||||
max-width: 450px !important; /* Enforce max-width on smaller screens */
|
||||
}
|
||||
|
||||
.login-section-box {
|
||||
min-height: auto;
|
||||
padding: 2rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
.main-card {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.login-section-box {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<v-card class="pa-6 rounded-xl elevation-4">
|
||||
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
|
||||
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
|
||||
<span>Edit Hak Akses Menu</span>
|
||||
</v-card-title>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-card-text class="px-0">
|
||||
<v-row v-if="localItem.role || localItem.group" class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
||||
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
|
||||
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="backendPermissions.length > 0">
|
||||
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
|
||||
<td class="kol-no text-center">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
|
||||
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
|
||||
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
|
||||
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
|
||||
Level {{ perm.level }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-status">
|
||||
<v-chip
|
||||
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.active"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.read"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.create"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.update"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.delete"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
|
||||
<td class="kol-no text-center">{{ index + 1 }}</td>
|
||||
<td>{{ menu.name }}</td>
|
||||
<td class="text-center kol-status">
|
||||
<v-chip color="success" size="small" variant="tonal">
|
||||
Mapped
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
class="text-capitalize mr-2"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
class="text-capitalize"
|
||||
@click="handleSave"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
|
||||
// Define types for better readability and safety
|
||||
interface HakAksesMenu {
|
||||
name: string;
|
||||
canAccess: boolean;
|
||||
canView: boolean;
|
||||
canAdd: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
interface BackendPermissionItem {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface HakAksesData {
|
||||
id: number;
|
||||
role?: string;
|
||||
group?: string;
|
||||
namaTipeUser: string;
|
||||
hakAksesMenu: HakAksesMenu[];
|
||||
}
|
||||
|
||||
// Define props with type validation
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as () => HakAksesData,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Define emits for clarity
|
||||
const emits = defineEmits(['save', 'cancel']);
|
||||
|
||||
// Use a local copy to avoid mutating the prop directly
|
||||
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
|
||||
const backendPermissions = ref<BackendPermissionItem[]>([]);
|
||||
const isSaving = ref(false);
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Helper function to normalize group name
|
||||
const normalizeGroup = (group: string): string => {
|
||||
const normalized = group.trim();
|
||||
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
|
||||
if (normalized.toLowerCase().includes('instalasi')) {
|
||||
const parts = normalized.split(/\s+/);
|
||||
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
|
||||
if (stimIndex !== -1) {
|
||||
return 'STIM';
|
||||
}
|
||||
}
|
||||
// Jika group adalah "Instalasi STIM", return "STIM"
|
||||
if (normalized.toLowerCase() === 'instalasi stim') {
|
||||
return 'STIM';
|
||||
}
|
||||
return normalized.toUpperCase();
|
||||
};
|
||||
|
||||
// Helper function to normalize role name
|
||||
const normalizeRole = (role: string): string => {
|
||||
const normalized = role.toLowerCase().trim();
|
||||
// Mapping khusus untuk role default
|
||||
if (normalized === 'default-roles-sandbox') {
|
||||
return 'superadmin';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API when component mounts
|
||||
onMounted(async () => {
|
||||
if (localItem.value.role && localItem.value.group) {
|
||||
try {
|
||||
// Normalize role and group before making API call
|
||||
const normalizedRole = normalizeRole(localItem.value.role);
|
||||
const normalizedGroup = normalizeGroup(localItem.value.group);
|
||||
|
||||
console.log('🔄 Fetching permissions with normalized values:', {
|
||||
originalRole: localItem.value.role,
|
||||
normalizedRole,
|
||||
originalGroup: localItem.value.group,
|
||||
normalizedGroup,
|
||||
});
|
||||
|
||||
const response = await $fetch<any>('/api/permission', {
|
||||
query: {
|
||||
roles: normalizedRole,
|
||||
groups: normalizedGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log(`✅ Received ${response.data.length} permissions from API`);
|
||||
// Create a deep copy to avoid mutating the original
|
||||
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
||||
...perm,
|
||||
}));
|
||||
} else {
|
||||
console.warn('⚠️ No data received from API or invalid structure');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching permissions:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Role or group is missing:', {
|
||||
role: localItem.value.role,
|
||||
group: localItem.value.group,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort permissions: level 1 first, then level 2 grouped under their parents
|
||||
const sortedPermissions = computed(() => {
|
||||
if (backendPermissions.value.length === 0) return [];
|
||||
|
||||
const level1 = backendPermissions.value.filter(p => p.level === 1);
|
||||
const level2 = backendPermissions.value.filter(p => p.level === 2);
|
||||
|
||||
// Sort level 1 by sort order
|
||||
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Sort level 2 by sort order
|
||||
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Build result: insert level 2 items after their parent
|
||||
const result: BackendPermissionItem[] = [];
|
||||
|
||||
level1.forEach(parent => {
|
||||
result.push(parent);
|
||||
// Add children of this parent
|
||||
const children = level2.filter(child => child.parent === parent.pagesID);
|
||||
result.push(...children);
|
||||
});
|
||||
|
||||
// Add any remaining level 2 items that don't have a parent match
|
||||
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
|
||||
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
|
||||
result.push(...remainingChildren);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
|
||||
const isPageMapped = (pagename: string): boolean => {
|
||||
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerPage = (pagename || '').toLowerCase();
|
||||
return localItem.value.hakAksesMenu.some((menu) => {
|
||||
const name = (menu.name || '').toLowerCase();
|
||||
return (
|
||||
name === lowerPage ||
|
||||
name.includes(lowerPage) ||
|
||||
lowerPage.includes(name)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface NavItemOrder {
|
||||
name: string;
|
||||
children?: NavItemOrder[];
|
||||
}
|
||||
|
||||
// Build a map of menu order based on the sidebar configuration so the list is always aligned
|
||||
const menuOrder = computed(() => {
|
||||
const order: Record<string, number> = {};
|
||||
const walk = (items: NavItemOrder[], startIndex = 0): number => {
|
||||
let idx = startIndex;
|
||||
items.forEach((item) => {
|
||||
order[item.name] = idx;
|
||||
idx += 1;
|
||||
if (item.children?.length) {
|
||||
idx = walk(item.children, idx);
|
||||
}
|
||||
});
|
||||
return idx;
|
||||
};
|
||||
walk(navItemsStore.navItems);
|
||||
return order;
|
||||
});
|
||||
|
||||
const orderedMenus = computed(() => {
|
||||
const order = menuOrder.value;
|
||||
return [...localItem.value.hakAksesMenu].sort((a, b) => {
|
||||
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle save - convert backend permissions back to menu structure if needed
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
// If we have backend permissions, we need to map them back to the menu structure
|
||||
if (backendPermissions.value.length > 0) {
|
||||
// Update the local item with backend permissions mapped to menu structure
|
||||
const updatedItem = {
|
||||
...localItem.value,
|
||||
backendPermissions: backendPermissions.value,
|
||||
};
|
||||
emits('save', updatedItem);
|
||||
} else {
|
||||
// Use existing menu structure
|
||||
emits('save', localItem.value);
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch the prop for changes and update the local copy
|
||||
watch(() => props.item, (newItem) => {
|
||||
localItem.value = JSON.parse(JSON.stringify(newItem));
|
||||
// Re-fetch permissions if role/group changed
|
||||
if (newItem.role && newItem.group) {
|
||||
// Normalize role and group before making API call
|
||||
const normalizedRole = normalizeRole(newItem.role);
|
||||
const normalizedGroup = normalizeGroup(newItem.group);
|
||||
|
||||
console.log('🔄 Re-fetching permissions with normalized values:', {
|
||||
originalRole: newItem.role,
|
||||
normalizedRole,
|
||||
originalGroup: newItem.group,
|
||||
normalizedGroup,
|
||||
});
|
||||
|
||||
$fetch<any>('/api/permission', {
|
||||
query: {
|
||||
roles: normalizedRole,
|
||||
groups: normalizedGroup,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log(`✅ Received ${response.data.length} permissions from API`);
|
||||
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
||||
...perm,
|
||||
}));
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('❌ Error fetching permissions:', error);
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-table :deep(th) {
|
||||
font-weight: bold !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.v-table :deep(td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-checkbox :deep(.v-selection-control__input) {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-no) {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-aksi) {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-status) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.cek-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<v-card
|
||||
class="pa-4 rounded-lg elevation-2 d-flex flex-column"
|
||||
color="white"
|
||||
style="border-top: 8px solid #FFB95F;"
|
||||
height="100%"
|
||||
>
|
||||
<div class="d-flex align-center justify-center mb-4">
|
||||
<v-icon size="36" color="orange-lighten-2" class="mr-2">mdi-hospital-box-outline</v-icon>
|
||||
<div class="text-h6 font-weight-bold text-orange-lighten-2">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
|
||||
<div class="timeline-scroll-container">
|
||||
<v-timeline density="compact" align="start" line-inset="12" line-color="#FFB95F">
|
||||
<v-timeline-item
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
:dot-color="getStepColor(index)"
|
||||
:icon="getStepIcon(index)"
|
||||
size="small"
|
||||
icon-color="white"
|
||||
>
|
||||
<div class="d-flex flex-column align-start">
|
||||
<span
|
||||
class="font-weight-bold"
|
||||
:class="index === currentStepIndex ? 'text-orange-lighten-1' : index < currentStepIndex ? 'text-success' : 'text-grey-darken-1'"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
<span class="text-caption text-grey-darken-1">
|
||||
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
|
||||
</span>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</div>
|
||||
|
||||
<v-divider class="mt-4 mb-4"></v-divider>
|
||||
|
||||
<v-btn
|
||||
size="large"
|
||||
variant="tonal"
|
||||
color="#FFA532"
|
||||
class="mt-auto font-weight-bold"
|
||||
prepend-icon="mdi-printer"
|
||||
block
|
||||
>
|
||||
Cetak Ulang Tiket
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
steps: Array,
|
||||
color: String,
|
||||
currentStepLabel: String,
|
||||
});
|
||||
|
||||
const currentStepIndex = computed(() => {
|
||||
return props.steps.findIndex(step => step.label === props.currentStepLabel);
|
||||
});
|
||||
|
||||
const getStepIcon = (index) => {
|
||||
if (index < currentStepIndex.value) {
|
||||
return 'mdi-check';
|
||||
} else if (index === currentStepIndex.value) {
|
||||
return 'mdi-progress-check';
|
||||
} else {
|
||||
return 'mdi-circle-outline';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepColor = (index) => {
|
||||
if (index < currentStepIndex.value) {
|
||||
return 'success';
|
||||
} else if (index === currentStepIndex.value) {
|
||||
return 'orange-lighten-1';
|
||||
}
|
||||
return 'grey-lighten-1';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =============================================== */
|
||||
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
|
||||
/* =============================================== */
|
||||
.timeline-scroll-container {
|
||||
/* Tinggi Tetap: Memastikan tinggi card konsisten di semua skenario responsive */
|
||||
height: 320px;
|
||||
|
||||
/* Membuat konten scrollable di sumbu Y jika melebihi height */
|
||||
overflow-y: auto;
|
||||
|
||||
/* Memberi jarak internal di bagian scrollable */
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* PENTING: Menghilangkan padding bawah dari V-Timeline bawaan */
|
||||
.v-timeline {
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Kustomisasi scrollbar untuk tampilan yang lebih bersih (Opsional) */
|
||||
.timeline-scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.timeline-scroll-container::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* V-TIMELINE KUSTOMISASI */
|
||||
/* =============================================== */
|
||||
.v-timeline-item :deep(.v-timeline-item__body) {
|
||||
padding-inline-start: 16px !important;
|
||||
}
|
||||
|
||||
.v-timeline-item :deep(.v-timeline-item__opposite) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="500"
|
||||
persistent
|
||||
transition="dialog-transition"
|
||||
scrim="rgba(0, 0, 0, 0.5)"
|
||||
class="blur-dialog"
|
||||
>
|
||||
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||
<div class="dialog-header text-center pa-8" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'success-header' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'warning-header' : 'error-header'">
|
||||
<div class="decorative-circles">
|
||||
<div class="circle circle-1"></div>
|
||||
<div class="circle circle-2"></div>
|
||||
<div class="circle circle-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="icon-wrapper mb-4">
|
||||
<div class="icon-bg" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'icon-bg-success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'icon-bg-warning' : 'icon-bg-error'">
|
||||
<v-icon size="64" color="white" class="dialog-icon">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-alert-circle' : 'mdi-close-circle' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-h4 font-weight-bold text-white mb-2">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin'
|
||||
? 'Check-in Berhasil!'
|
||||
: lastCheckInResult?.status === 'NOT_ALLOWED'
|
||||
? 'Belum Diizinkan'
|
||||
: 'Check-in Gagal' }}
|
||||
</h2>
|
||||
|
||||
<div class="status-badge mt-3">
|
||||
<v-chip
|
||||
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'white' : 'white'"
|
||||
:text-color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'"
|
||||
size="small"
|
||||
class="font-weight-bold"
|
||||
>
|
||||
<v-icon start size="16">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
|
||||
</v-icon>
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin'
|
||||
? 'Berhasil'
|
||||
: lastCheckInResult?.status === 'NOT_ALLOWED'
|
||||
? 'Menunggu'
|
||||
: 'Gagal' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-8">
|
||||
<div class="message-container">
|
||||
<div class="message-icon mb-4">
|
||||
<v-icon :color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'" size="32">
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<div class="text-h6 font-weight-bold mb-3 text-center" style="white-space: pre-line;">{{ infoMessage }}</div>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<div v-if="lastCheckInResult" class="instruction-box pa-4 rounded-lg" :class="lastCheckInResult.success && infoAction === 'checkin' ? 'instruction-success' : 'instruction-warning'">
|
||||
<div class="d-flex align-start">
|
||||
<v-icon :color="lastCheckInResult.success && infoAction === 'checkin' ? 'success' : 'orange'" class="mr-3 mt-1">
|
||||
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'mdi-check-circle' : 'mdi-timer-sand' }}
|
||||
</v-icon>
|
||||
<div>
|
||||
<p class="text-body-1 font-weight-medium mb-1">
|
||||
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'Status Check-in:' : 'Status:' }}
|
||||
</p>
|
||||
<p class="text-body-2 text-grey-darken-1">
|
||||
{{ lastCheckInResult.success && infoAction === 'checkin'
|
||||
? 'Check-in telah berhasil dilakukan. Pasien dapat melanjutkan ke tahap selanjutnya.'
|
||||
: lastCheckInResult.status === 'NOT_ALLOWED'
|
||||
? 'Mohon menunggu hingga antrean Anda dipanggil oleh petugas'
|
||||
: 'Proses check-in gagal. Silakan coba lagi atau hubungi petugas.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Info Card -->
|
||||
<v-card variant="outlined" class="mt-4 patient-info-card" color="grey-lighten-4">
|
||||
<v-card-text class="py-3">
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="mr-2 patient-info-icon">mdi-account-circle</v-icon>
|
||||
<div>
|
||||
<p class="text-caption patient-info-label mb-0">ID Pasien</p>
|
||||
<p class="text-body-1 patient-info-value mb-0">{{ scannedData?.split('|')[0] || 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-caption patient-info-label mb-0">Waktu Scan</p>
|
||||
<p class="text-body-2 patient-info-value mb-0">{{ new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-row dense>
|
||||
<v-col v-if="infoAction === 'kembali'" cols="12">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
class="text-white font-weight-bold text-none dialog-button"
|
||||
size="x-large"
|
||||
block
|
||||
variant="flat"
|
||||
@click="handleClose"
|
||||
elevation="0"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
>
|
||||
Kembali
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<template v-else>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : 'primary'"
|
||||
class="text-white font-weight-bold text-none dialog-button"
|
||||
size="x-large"
|
||||
block
|
||||
variant="flat"
|
||||
@click="handleClose"
|
||||
elevation="4"
|
||||
:prepend-icon="lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : 'mdi-close'"
|
||||
>
|
||||
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'Tutup' : 'Tutup' }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CheckInResult, InfoAction } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
lastCheckInResult: CheckInResult | null
|
||||
infoMessage: string
|
||||
infoAction: InfoAction
|
||||
scannedData: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'close': []
|
||||
}>()
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="header-modern">
|
||||
<div class="header-content">
|
||||
<div class="icon-circle">
|
||||
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="title-modern">Check-in Pasien</h1>
|
||||
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Minimalis -->
|
||||
<v-tabs
|
||||
:model-value="modelValue"
|
||||
@update:model-value="updateTab"
|
||||
align-tabs="center"
|
||||
class="tabs-modern mt-3"
|
||||
bg-color="transparent"
|
||||
slider-color="#FB8C00"
|
||||
height="40"
|
||||
>
|
||||
<v-tab value="scan" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||
<span>Scan QR</span>
|
||||
</v-tab>
|
||||
<v-tab value="manual" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
|
||||
<span>Manual</span>
|
||||
</v-tab>
|
||||
<v-tab value="generate" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
|
||||
<span>Generate QR</span>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabValue } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: TabValue
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: TabValue): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const updateTab = (value: TabValue) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Header Modern Minimalis */
|
||||
.header-modern {
|
||||
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
|
||||
padding: 20px 24px 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-modern::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-modern {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle-modern {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 2px 0 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tabs-modern {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-tab) {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
min-width: 120px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-tab:hover) {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-tab--selected) {
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs-modern :deep(.v-slider) {
|
||||
background-color: #FB8C00 !important;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import QRCode from 'qrcode';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
patientId: string;
|
||||
status: string;
|
||||
generatedQRData: string | null;
|
||||
statusOptions: Array<{ title: string; value: string }>;
|
||||
primaryColor: string;
|
||||
generateRandomPatientId: () => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:patientId': [value: string];
|
||||
'update:status': [value: string];
|
||||
'generate': [];
|
||||
'quick-generate': [patientId: string, status: string];
|
||||
'download': [];
|
||||
'copy': [];
|
||||
'share': [];
|
||||
'generate-random-id': [];
|
||||
}>();
|
||||
|
||||
const localPatientId = computed({
|
||||
get: () => props.patientId,
|
||||
set: (value) => emit('update:patientId', value),
|
||||
});
|
||||
|
||||
const localStatus = computed({
|
||||
get: () => props.status,
|
||||
set: (value) => emit('update:status', value),
|
||||
});
|
||||
|
||||
const handleGenerate = () => {
|
||||
emit('generate');
|
||||
};
|
||||
|
||||
const handleQuickGenerate = (status: string) => {
|
||||
const randomId = props.generateRandomPatientId();
|
||||
emit('quick-generate', randomId, status);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
emit('download');
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
emit('copy');
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
emit('share');
|
||||
};
|
||||
|
||||
const handleGenerateRandomId = () => {
|
||||
emit('generate-random-id');
|
||||
};
|
||||
|
||||
const qrContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Function to render QR code
|
||||
const renderQRCode = async (data: string) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready
|
||||
await nextTick();
|
||||
|
||||
// Wait a bit more to ensure ref is mounted (especially when switching tabs)
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
if (!qrContainerRef.value) {
|
||||
console.warn('QR container ref not available yet, retrying...');
|
||||
// Retry after a short delay
|
||||
setTimeout(() => {
|
||||
if (qrContainerRef.value && data) {
|
||||
renderQRCode(data);
|
||||
}
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear previous content
|
||||
qrContainerRef.value.innerHTML = '';
|
||||
|
||||
// Generate QR code
|
||||
const qrDataUrl = await QRCode.toDataURL(data, {
|
||||
errorCorrectionLevel: 'M',
|
||||
type: 'image/png',
|
||||
quality: 0.92,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
width: 300,
|
||||
});
|
||||
|
||||
// Create and append image
|
||||
const img = document.createElement('img');
|
||||
img.src = qrDataUrl;
|
||||
img.alt = 'QR Code';
|
||||
img.style.width = '100%';
|
||||
img.style.maxWidth = '300px';
|
||||
img.style.height = 'auto';
|
||||
img.style.display = 'block';
|
||||
img.style.margin = '0 auto';
|
||||
qrContainerRef.value.appendChild(img);
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for generatedQRData changes to render QR code
|
||||
watch(() => props.generatedQRData, async (newData) => {
|
||||
if (newData) {
|
||||
// Use setTimeout to ensure DOM is ready, especially when switching tabs
|
||||
setTimeout(() => {
|
||||
renderQRCode(newData);
|
||||
}, 200);
|
||||
} else if (qrContainerRef.value) {
|
||||
// Clear container if data is cleared
|
||||
qrContainerRef.value.innerHTML = '';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Also watch for when component is mounted and ref is ready
|
||||
onMounted(() => {
|
||||
if (props.generatedQRData) {
|
||||
// Delay to ensure ref is mounted
|
||||
setTimeout(() => {
|
||||
renderQRCode(props.generatedQRData!);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">Generate QR Code untuk Testing</h3>
|
||||
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Preset Buttons -->
|
||||
<div class="mb-3">
|
||||
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="small"
|
||||
@click="handleQuickGenerate('ALLOWED')"
|
||||
class="text-none"
|
||||
block
|
||||
>
|
||||
<v-icon start size="16">mdi-check-circle</v-icon>
|
||||
Test ALLOWED
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="handleQuickGenerate('NOT_ALLOWED')"
|
||||
class="text-none"
|
||||
block
|
||||
>
|
||||
<v-icon start size="16">mdi-clock-alert</v-icon>
|
||||
Test NOT_ALLOWED
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Form Generate -->
|
||||
<v-form @submit.prevent="handleGenerate">
|
||||
<v-text-field
|
||||
v-model="localPatientId"
|
||||
label="ID Pasien"
|
||||
placeholder="Contoh: P-123456"
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-3"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details="auto"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="handleGenerateRandomId"
|
||||
title="Generate Random ID"
|
||||
>
|
||||
<v-icon size="20">mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-select
|
||||
v-model="localStatus"
|
||||
label="Status Check-in"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-shield-check"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-4"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
></v-select>
|
||||
|
||||
<div class="d-flex justify-center">
|
||||
<v-btn
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
type="submit"
|
||||
elevation="0"
|
||||
:disabled="!localPatientId"
|
||||
>
|
||||
<v-icon start size="20">mdi-qrcode-plus</v-icon>
|
||||
Generate QR Code
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
|
||||
<!-- QR Code Display -->
|
||||
<div v-if="props.generatedQRData" class="qr-display mt-6">
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<div class="text-center">
|
||||
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
|
||||
<div ref="qrContainerRef" class="qr-code-container mb-4"></div>
|
||||
|
||||
<v-chip :color="localStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
|
||||
<v-icon start>{{ localStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
|
||||
{{ localStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
|
||||
</v-chip>
|
||||
|
||||
<p class="text-body-2 text-grey mb-4">
|
||||
Data: {{ props.generatedQRData }}
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="handleDownload"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-download</v-icon>
|
||||
Download
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="handleCopy"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-content-copy</v-icon>
|
||||
Copy
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="handleShare"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-share-variant</v-icon>
|
||||
Share
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Instructions -->
|
||||
<v-alert
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mt-4 text-body-2"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</template>
|
||||
<strong>Cara menggunakan untuk Testing:</strong>
|
||||
<ol class="ml-4 mt-2">
|
||||
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
|
||||
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
|
||||
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
|
||||
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
|
||||
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
|
||||
</ol>
|
||||
</v-alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="800"
|
||||
persistent
|
||||
transition="dialog-transition"
|
||||
scrim="rgba(0, 0, 0, 0.5)"
|
||||
class="blur-dialog"
|
||||
>
|
||||
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
|
||||
<h2 class="text-h5 font-weight-bold text-white mb-2">
|
||||
<v-icon color="white" class="mr-2">mdi-history</v-icon>
|
||||
Riwayat Check-in
|
||||
</h2>
|
||||
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<!-- Filter dan Search -->
|
||||
<div class="mb-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
:model-value="search"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
label="Cari ID Pasien atau Nomor Antrean"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
:model-value="dateFilter"
|
||||
@update:model-value="$emit('update:dateFilter', $event || '')"
|
||||
type="date"
|
||||
label="Filter Tanggal"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
class="date-picker-field"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<v-select
|
||||
:model-value="statusFilter"
|
||||
@update:model-value="$emit('update:statusFilter', $event)"
|
||||
label="Filter Status"
|
||||
:items="statusOptions"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="$emit('clear')"
|
||||
:disabled="history.length === 0"
|
||||
>
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Hapus Semua
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-if="filteredHistory.length > 0" class="history-list">
|
||||
<v-card
|
||||
v-for="(item, index) in filteredHistory"
|
||||
:key="index"
|
||||
variant="outlined"
|
||||
class="mb-3 history-item"
|
||||
:class="getStatusClass(item.status)"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex justify-space-between align-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
|
||||
{{ getStatusText(item.status) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="grey-lighten-1"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
>
|
||||
{{ item.method }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<p class="text-body-1 font-weight-bold mb-1">
|
||||
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
|
||||
ID Pasien: {{ item.patientId }}
|
||||
</p>
|
||||
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
|
||||
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
|
||||
Nomor Antrean: {{ item.queueNumber }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-clock-outline</v-icon>
|
||||
{{ formatDateTime(item.checkInTime) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="item.checkInDate"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-calendar</v-icon>
|
||||
{{ formatDate(item.checkInDate) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="$emit('delete-item', index)"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
|
||||
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
|
||||
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
class="text-white font-weight-bold text-none"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CheckInHistoryItem, HistoryStatus } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
search: string
|
||||
dateFilter: string
|
||||
statusFilter: HistoryStatus | ''
|
||||
filteredHistory: CheckInHistoryItem[]
|
||||
history: CheckInHistoryItem[] | ReadonlyArray<CheckInHistoryItem>
|
||||
statusOptions: Array<{ title: string; value: string }>
|
||||
primaryColor: string
|
||||
getStatusColor: (status: string) => string
|
||||
getStatusIcon: (status: string) => string
|
||||
getStatusText: (status: string) => string
|
||||
getStatusClass: (status: string) => string
|
||||
formatDateTime: (dateString: string) => string
|
||||
formatDate: (dateString: string) => string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'update:search': [value: string]
|
||||
'update:dateFilter': [value: string]
|
||||
'update:statusFilter': [value: HistoryStatus | '']
|
||||
'delete-item': [index: number]
|
||||
'clear': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
'submit': [];
|
||||
'open-history': [];
|
||||
}>();
|
||||
|
||||
const manualForm = ref<{ validate: () => boolean; resetValidation: () => void; reset: () => void } | null>(null);
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit');
|
||||
};
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
emit('open-history');
|
||||
};
|
||||
|
||||
// Expose form ref for parent component
|
||||
defineExpose({
|
||||
form: manualForm,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="secondaryColor" size="24">mdi-keyboard</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">Input Manual</h3>
|
||||
<p class="status-subtitle">Masukkan nomor antrean atau ID pasien</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-form @submit.prevent="handleSubmit" ref="manualForm">
|
||||
<v-text-field
|
||||
v-model="localValue"
|
||||
label="Nomor Antrean / ID Pasien"
|
||||
placeholder="Contoh: P12345"
|
||||
prepend-inner-icon="mdi-account-card-details"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-4"
|
||||
required
|
||||
:rules="[v => !!v || 'Field ini wajib diisi']"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-icon :color="localValue ? 'success' : 'grey'" size="20">
|
||||
{{ localValue ? 'mdi-check-circle' : 'mdi-circle-outline' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
type="submit"
|
||||
elevation="0"
|
||||
>
|
||||
<v-icon start size="20">mdi-login</v-icon>
|
||||
Check-in Sekarang
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<!-- Quick Access Buttons -->
|
||||
<div class="quick-actions mt-4">
|
||||
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
class="text-none"
|
||||
@click="handleOpenHistory"
|
||||
>
|
||||
<v-icon start size="18">mdi-history</v-icon>
|
||||
Riwayat
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="800"
|
||||
persistent
|
||||
transition="dialog-transition"
|
||||
scrim="rgba(0, 0, 0, 0.5)"
|
||||
class="blur-dialog"
|
||||
>
|
||||
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #FB8C00 0%, #F57C00 100%);">
|
||||
<h2 class="text-h5 font-weight-bold text-white mb-2">
|
||||
<v-icon color="white" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||
Riwayat QR Scan
|
||||
</h2>
|
||||
<p class="text-body-2 text-white opacity-90">Daftar QR code yang telah di-scan</p>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<!-- Filter dan Search -->
|
||||
<div class="mb-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="5">
|
||||
<v-text-field
|
||||
:model-value="search"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
label="Cari Data QR Code"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
:model-value="dateFilter"
|
||||
@update:model-value="$emit('update:dateFilter', $event || '')"
|
||||
type="date"
|
||||
label="Filter Tanggal"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
class="date-picker-field"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="$emit('clear')"
|
||||
:disabled="history.length === 0"
|
||||
>
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Hapus Semua
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- QR History List -->
|
||||
<div v-if="filteredHistory.length > 0" class="history-list">
|
||||
<v-card
|
||||
v-for="(item, index) in filteredHistory"
|
||||
:key="index"
|
||||
variant="outlined"
|
||||
class="mb-3 history-item"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex justify-space-between align-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="mb-2">
|
||||
<p class="text-body-1 font-weight-bold mb-1">
|
||||
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-qrcode</v-icon>
|
||||
Data QR: {{ item.data }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-clock-outline</v-icon>
|
||||
{{ item.time }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
>
|
||||
<v-icon start size="14">mdi-calendar</v-icon>
|
||||
{{ item.date }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="$emit('use-data', item.data)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="$emit('delete-item', index)"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-qrcode-scan</v-icon>
|
||||
<p class="text-h6 text-grey mb-2">Belum ada riwayat QR scan</p>
|
||||
<p class="text-body-2 text-grey">Riwayat QR scan akan muncul di sini setelah Anda melakukan scan</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
class="text-white font-weight-bold text-none"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ScannedQRHistoryItem } from '~/types/checkin'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
search: string
|
||||
dateFilter: string
|
||||
filteredHistory: ScannedQRHistoryItem[]
|
||||
history: ScannedQRHistoryItem[] | ReadonlyArray<ScannedQRHistoryItem>
|
||||
primaryColor: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'update:search': [value: string]
|
||||
'update:dateFilter': [value: string]
|
||||
'use-data': [data: string]
|
||||
'delete-item': [index: number]
|
||||
'clear': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isScanning: boolean;
|
||||
hasCamera: boolean;
|
||||
cameraChecking: boolean;
|
||||
cameraReady: boolean;
|
||||
primaryColor: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'start-scanning': [];
|
||||
'stop-scanning': [];
|
||||
'test-camera': [];
|
||||
'open-history': [];
|
||||
'open-qr-history': [];
|
||||
}>();
|
||||
|
||||
const handleStartScanning = () => {
|
||||
emit('start-scanning');
|
||||
};
|
||||
|
||||
const handleStopScanning = () => {
|
||||
emit('stop-scanning');
|
||||
};
|
||||
|
||||
const handleTestCamera = () => {
|
||||
emit('test-camera');
|
||||
};
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
emit('open-history');
|
||||
};
|
||||
|
||||
const handleOpenQRHistory = () => {
|
||||
emit('open-qr-history');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="primaryColor" size="24">mdi-qrcode-scan</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">
|
||||
{{ isScanning ? 'Arahkan Kamera ke QR Code' : 'Siap untuk Scan QR Code' }}
|
||||
</h3>
|
||||
<p class="status-subtitle">
|
||||
{{ isScanning ? 'Pastikan QR code terlihat jelas dan tidak terpotong' : 'Klik tombol di bawah untuk memulai scan' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Status Check -->
|
||||
<div v-if="cameraChecking" class="text-center mb-4">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
:color="primaryColor"
|
||||
size="24"
|
||||
class="mr-2"
|
||||
></v-progress-circular>
|
||||
<span class="text-body-2 text-grey">Memeriksa ketersediaan kamera...</span>
|
||||
</div>
|
||||
|
||||
<!-- Camera Not Available Warning -->
|
||||
<v-alert
|
||||
v-else-if="!hasCamera && !isScanning"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon>mdi-camera-off</v-icon>
|
||||
</template>
|
||||
<div>
|
||||
<p class="font-weight-bold mb-1">Kamera tidak terdeteksi</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
Perangkat Anda tidak memiliki kamera atau kamera tidak dapat diakses.
|
||||
Silakan gunakan tab <strong>Manual</strong> untuk input data secara manual.
|
||||
</p>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- QR Scanner Area dengan webcam -->
|
||||
<div class="qr-scanner-container mb-4">
|
||||
<div v-if="!isScanning" class="qr-placeholder">
|
||||
<div class="scanner-overlay">
|
||||
<div class="corner corner-tl"></div>
|
||||
<div class="corner corner-tr"></div>
|
||||
<div class="corner corner-bl"></div>
|
||||
<div class="corner corner-br"></div>
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<v-icon size="64" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
|
||||
</div>
|
||||
<div v-else class="qr-reader-container">
|
||||
<div class="scanner-status mb-2">
|
||||
<v-chip color="success" size="small" class="mr-2">
|
||||
<v-icon start size="16">mdi-camera</v-icon>
|
||||
Kamera Aktif
|
||||
</v-chip>
|
||||
<span class="text-caption text-grey">Preview kamera sedang berjalan</span>
|
||||
</div>
|
||||
<div id="qr-reader" class="qr-reader-wrapper">
|
||||
<div v-if="!cameraReady" class="scanner-loading-overlay">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="white"
|
||||
size="48"
|
||||
></v-progress-circular>
|
||||
<p class="text-white mt-4">Memuat kamera...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scanner-instruction">
|
||||
<v-icon :color="primaryColor" size="20" class="mr-2">mdi-information</v-icon>
|
||||
<span class="text-body-2">Arahkan kamera ke QR code. Pastikan QR code berada dalam kotak pemindaian.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button Minimalis -->
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
v-if="!isScanning"
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
elevation="0"
|
||||
@click="handleStartScanning"
|
||||
:disabled="!hasCamera && !cameraChecking"
|
||||
>
|
||||
<v-icon start size="20">mdi-camera</v-icon>
|
||||
{{ hasCamera ? 'Mulai Scan QR' : 'Kamera Tidak Tersedia' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
class="btn-stop-modern btn-centered"
|
||||
size="large"
|
||||
elevation="0"
|
||||
@click="handleStopScanning"
|
||||
>
|
||||
<v-icon start size="20">mdi-camera-off</v-icon>
|
||||
Stop Scan
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Info tambahan -->
|
||||
<div class="info-card mt-3">
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
:color="primaryColor"
|
||||
class="text-body-2 info-alert-centered"
|
||||
density="compact"
|
||||
>
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-icon size="16" class="mr-2">mdi-lightbulb-outline</v-icon>
|
||||
<span style="font-size: 12px;">Tips: Pastikan pencahayaan cukup untuk hasil scan optimal</span>
|
||||
</div>
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<!-- Test Camera Button (Debug) -->
|
||||
<div class="test-camera-section mt-2 mb-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="text-none btn-centered-small btn-test-camera"
|
||||
@click="handleTestCamera"
|
||||
>
|
||||
<v-icon start size="18" color="#1565C0">mdi-camera</v-icon>
|
||||
Test Kamera
|
||||
</v-btn>
|
||||
<p class="text-caption text-grey text-center mt-1" style="font-size: 11px;">
|
||||
Klik untuk menguji apakah browser dapat mengakses kamera
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access Buttons -->
|
||||
<div class="quick-actions mt-4">
|
||||
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
class="text-none"
|
||||
@click="handleOpenHistory"
|
||||
>
|
||||
<v-icon start size="18">mdi-history</v-icon>
|
||||
Riwayat Check-in
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
block
|
||||
size="small"
|
||||
class="text-none"
|
||||
@click="handleOpenQRHistory"
|
||||
>
|
||||
<v-icon start size="18" color="#1565C0">mdi-qrcode-scan</v-icon>
|
||||
Riwayat QR Scan
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/components';
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="stats-footer-modern mt-3">
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ todayCount }}</div>
|
||||
<div class="stat-label">Hari Ini</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon color="success" size="20">mdi-check-circle</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ completedCount }}</div>
|
||||
<div class="stat-label">Selesai</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ pendingCount }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CheckInHistoryItem } from '~/types/checkin'
|
||||
import { PRIMARY_COLOR, SECONDARY_COLOR } from '~/constants/checkin'
|
||||
|
||||
interface Props {
|
||||
history: CheckInHistoryItem[]
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
primaryColor: PRIMARY_COLOR,
|
||||
secondaryColor: SECONDARY_COLOR,
|
||||
})
|
||||
|
||||
// Computed untuk menghitung stats
|
||||
const todayCount = computed(() => {
|
||||
const today = new Date().toDateString()
|
||||
return props.history.filter(item => {
|
||||
const itemDate = new Date(item.checkInDate).toDateString()
|
||||
return itemDate === today
|
||||
}).length
|
||||
})
|
||||
|
||||
const completedCount = computed(() => {
|
||||
return props.history.filter(item =>
|
||||
item.status === 'success' || item.status === 'ALLOWED'
|
||||
).length
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => {
|
||||
return props.history.filter(item =>
|
||||
item.status === 'pending' || item.status === 'NOT_ALLOWED'
|
||||
).length
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Stats Footer Modern */
|
||||
.stats-footer-modern {
|
||||
animation: slideUp 0.5s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card-modern {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card-modern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card-modern:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: #1565C0;
|
||||
}
|
||||
|
||||
.stat-card-modern:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.stat-icon-modern {
|
||||
margin-bottom: 12px;
|
||||
display: inline-flex;
|
||||
padding: 8px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<v-snackbar v-model="showModel" :color="color" :timeout="timeout">
|
||||
<span class="body-3">{{ message }}</span>
|
||||
<template #actions>
|
||||
<v-btn variant="text" size="small" @click="showModel = false">
|
||||
{{ closeText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'success'
|
||||
},
|
||||
timeout: {
|
||||
type: Number,
|
||||
default: 3000
|
||||
},
|
||||
closeText: {
|
||||
type: String,
|
||||
default: 'Tutup'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const showModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="field-group">
|
||||
<div class="group-label">
|
||||
<v-icon v-if="icon" size="18" class="icon-label">{{ icon }}</v-icon>
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-400: #E5F7FA;
|
||||
$primary-600: #FFA532;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.field-group {
|
||||
background: $neutral-100;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0;
|
||||
border: 1px solid $neutral-400;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $primary-600;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
color: $primary-600 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="page-header" :class="`page-header-${theme}`">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">{{ icon }}</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="page-title">{{ title }}</h2>
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="actions">
|
||||
<v-btn
|
||||
v-if="showAddButton"
|
||||
color="white"
|
||||
elevation="0"
|
||||
class="add-btn"
|
||||
:class="`add-btn-${theme}`"
|
||||
@click="$emit('add-click')"
|
||||
>
|
||||
<v-icon left size="20">mdi-plus-circle</v-icon>
|
||||
{{ addButtonText }}
|
||||
</v-btn>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'mdi-view-dashboard'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showAddButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
addButtonText: {
|
||||
type: String,
|
||||
default: 'Tambah'
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'primary', // 'primary', 'secondary', 'success', or 'accent'
|
||||
validator: (value) => ['primary', 'secondary', 'success', 'accent'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['add-click']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$primary-600: #FFA532;
|
||||
$primary-700: #FF9B1B;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$success-600: #009262;
|
||||
$success-700: #1B6E53;
|
||||
$accent-600: #8B5CF6;
|
||||
$accent-700: #7C3AED;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.page-header {
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.page-header-primary {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
|
||||
}
|
||||
|
||||
.page-header-secondary {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(6, 113, 224, 0.2);
|
||||
}
|
||||
|
||||
.page-header-success {
|
||||
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
|
||||
}
|
||||
|
||||
.page-header-accent {
|
||||
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: $neutral-100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
line-height: 44px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
color: $neutral-100;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $neutral-100;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.add-btn-primary {
|
||||
color: $primary-600 !important;
|
||||
}
|
||||
|
||||
.add-btn-secondary {
|
||||
color: $secondary-600 !important;
|
||||
}
|
||||
|
||||
.add-btn-success {
|
||||
color: $success-600 !important;
|
||||
}
|
||||
|
||||
.add-btn-accent {
|
||||
color: $accent-600 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="800px">
|
||||
<v-card>
|
||||
<v-card-title class="dialog-header">
|
||||
<v-btn icon size="small" @click="dialogModel = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<span>{{ title }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
:placeholder="searchPlaceholder"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
|
||||
<v-row dense>
|
||||
<v-col
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
cols="6"
|
||||
sm="4"
|
||||
>
|
||||
<v-card
|
||||
class="selection-card"
|
||||
@click="handleSelect(item)"
|
||||
elevation="0"
|
||||
>
|
||||
<v-card-text class="text-center pa-3">
|
||||
<div class="selection-name">{{ item.name }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Pilih Item'
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: 'Cari...'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.searchQuery,
|
||||
set: (value) => emit('update:searchQuery', value)
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchModel.value) return props.items;
|
||||
const search = searchModel.value.toLowerCase();
|
||||
return props.items.filter(item =>
|
||||
item.name.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const handleSelect = (item) => {
|
||||
emit('select', item);
|
||||
dialogModel.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dialog-header {
|
||||
background: var(--color-neutral-300);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.selection-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.selection-card:hover {
|
||||
border-color: var(--color-secondary-600);
|
||||
background: var(--color-secondary-200);
|
||||
}
|
||||
|
||||
.selection-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<v-card class="filter-card" elevation="0">
|
||||
<v-card-text>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Pencarian"
|
||||
:placeholder="searchPlaceholder"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
class="search-field"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
:model-value="statusFilter"
|
||||
@update:model-value="$emit('update:statusFilter', $event)"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
class="filter-select"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-btn
|
||||
color="primary-600"
|
||||
variant="outlined"
|
||||
block
|
||||
size="large"
|
||||
@click="$emit('refresh')"
|
||||
:loading="loading"
|
||||
class="refresh-btn"
|
||||
>
|
||||
<v-icon start>mdi-refresh</v-icon>
|
||||
Refresh Data
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
statusFilter: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
statusOptions: {
|
||||
type: Array,
|
||||
default: () => ['Terdaftar', 'Belum Terdaftar', 'Proses']
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: 'Cari nama pasien, No. RM, atau alamat...'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['update:searchQuery', 'update:statusFilter', 'refresh']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-500: #ABBED1;
|
||||
$primary-600: #FFA532;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.filter-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $neutral-500;
|
||||
background: $neutral-100;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.filter-select {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="600px">
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header" :class="`dialog-header-${type}`">
|
||||
<v-icon class="mr-2">{{ icon }}</v-icon>
|
||||
<span>{{ title }}</span>
|
||||
<v-spacer />
|
||||
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- patient info card -->
|
||||
<div v-if="patient" class="patient-card mb-4">
|
||||
<h4 class="card-title">Informasi Pasien</h4>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<div class="info-label">Nama</div>
|
||||
<div class="info-value">{{ patient.namaPasien }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="info-label">No. RM</div>
|
||||
<div class="info-value">{{ patient.noRM }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- search field -->
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="searchLabel"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
class="mb-4 search-field"
|
||||
/>
|
||||
|
||||
<!-- options grid -->
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.id"
|
||||
cols="6"
|
||||
>
|
||||
<v-card
|
||||
class="option-card"
|
||||
:class="`option-card-${type}`"
|
||||
@click="$emit('select', option)"
|
||||
elevation="0"
|
||||
>
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon :size="32" :color="iconColor" class="mb-2">
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<div class="option-title">{{ option.name }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'klinik', // 'klinik' | 'penunjang'
|
||||
validator: (value) => ['klinik', 'penunjang'].includes(value)
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
searchLabel: {
|
||||
type: String,
|
||||
default: 'Cari...'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.searchQuery,
|
||||
set: (value) => emit('update:searchQuery', value)
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchModel.value) return props.options;
|
||||
const search = searchModel.value.toLowerCase();
|
||||
return props.options.filter(opt =>
|
||||
opt.name.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const iconColor = computed(() => {
|
||||
return props.type === 'klinik' ? 'secondary-600' : 'accent-600';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-700: #717171;
|
||||
$neutral-900: #212121;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$accent-600: #8B5CF6;
|
||||
$accent-700: #7C3AED;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
color: $neutral-100;
|
||||
padding: 20px 24px;
|
||||
font-size: 18px;
|
||||
font-weight: $font-weight-semibold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-header-klinik {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
}
|
||||
|
||||
.dialog-header-penunjang {
|
||||
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $neutral-300;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $neutral-500;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
margin-bottom: 12px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: $neutral-700;
|
||||
margin-bottom: 4px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.option-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid $neutral-500;
|
||||
background: $neutral-100;
|
||||
}
|
||||
|
||||
.option-card-klinik:hover {
|
||||
border-color: $secondary-600;
|
||||
background: var(--color-secondary-200);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.option-card-penunjang:hover {
|
||||
border-color: $accent-600;
|
||||
background: var(--color-accent-200);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="800px">
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header">
|
||||
<v-icon class="mr-2">mdi-door-open</v-icon>
|
||||
<span>Pilih Klinik Ruang</span>
|
||||
<v-spacer />
|
||||
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Patient Info Card -->
|
||||
<div v-if="patient" class="patient-card mb-4">
|
||||
<h4 class="card-title">Informasi Pasien</h4>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<div class="info-label">Nama</div>
|
||||
<div class="info-value">{{ patient.namaPasien }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="info-label">No. RM</div>
|
||||
<div class="info-value">{{ patient.noRM }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Search Field -->
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Cari Klinik Ruang"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
class="mb-4 search-field"
|
||||
/>
|
||||
|
||||
<!-- Ruang List with Expansion Panels -->
|
||||
<v-expansion-panels class="ruang-panels">
|
||||
<v-expansion-panel
|
||||
v-for="klinikRuang in filteredRuang"
|
||||
:key="klinikRuang.id"
|
||||
class="panel-item"
|
||||
>
|
||||
<v-expansion-panel-title class="panel-title">
|
||||
<div class="d-flex align-center w-100">
|
||||
<v-chip size="small" color="primary-600" class="mr-2 kode-chip">
|
||||
{{ klinikRuang.kodeKlinik }}
|
||||
</v-chip>
|
||||
<strong class="klinik-name">{{ klinikRuang.namaKlinik }}</strong>
|
||||
<v-spacer />
|
||||
<v-chip size="small" variant="outlined" class="count-chip">
|
||||
{{ klinikRuang.ruangList.length }} Ruang
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<v-list density="compact" class="ruang-list">
|
||||
<v-list-item
|
||||
v-for="ruang in klinikRuang.ruangList"
|
||||
:key="ruang.nomorRuang"
|
||||
@click="$emit('select', klinikRuang, ruang)"
|
||||
class="ruang-item"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon color="primary-600" size="20">mdi-door</v-icon>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="ruang-title">
|
||||
<strong>Ruang {{ ruang.nomorRuang }}</strong> - {{ ruang.namaRuang }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="ruang-subtitle">
|
||||
Screen: {{ ruang.nomorScreen }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-icon size="20">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
ruangList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.searchQuery,
|
||||
set: (value) => emit('update:searchQuery', value)
|
||||
});
|
||||
|
||||
const filteredRuang = computed(() => {
|
||||
if (!searchModel.value) return props.ruangList;
|
||||
const search = searchModel.value.toLowerCase();
|
||||
return props.ruangList.filter(k =>
|
||||
k.namaKlinik.toLowerCase().includes(search) ||
|
||||
k.kodeKlinik.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-700: #717171;
|
||||
$neutral-900: #212121;
|
||||
$primary-600: #FFA532;
|
||||
$primary-700: #FF9B1B;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 20px 24px;
|
||||
font-size: 18px;
|
||||
font-weight: $font-weight-semibold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $neutral-300;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $neutral-500;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
margin-bottom: 12px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: $neutral-700;
|
||||
margin-bottom: 4px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.ruang-panels {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid $neutral-500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.kode-chip {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.klinik-name {
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
font-size: 12px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.ruang-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ruang-item {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.2s;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.ruang-item:hover {
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.ruang-title {
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $neutral-900;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.ruang-subtitle {
|
||||
font-size: 12px;
|
||||
color: $neutral-700;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<v-card elevation="0" class="patient-table-card">
|
||||
<v-card-title class="table-title">
|
||||
<v-icon class="mr-2" color="primary-600">mdi-account-group</v-icon>
|
||||
<span>Daftar Pasien - {{ items.length }} pasien</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:search="searchQuery"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-0 data-table"
|
||||
density="comfortable"
|
||||
>
|
||||
<template #item.namaPasien="{ item }">
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ item.namaPasien }}</div>
|
||||
<div class="patient-meta">{{ item.noRM }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
variant="flat"
|
||||
class="status-chip"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.buatAntrean="{ item }">
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="secondary-600"
|
||||
variant="outlined"
|
||||
@click="$emit('create-klinik', item)"
|
||||
class="action-btn"
|
||||
>
|
||||
<v-icon size="16" start>mdi-hospital-building</v-icon>
|
||||
Klinik
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary-600"
|
||||
variant="outlined"
|
||||
@click="$emit('create-ruang', item)"
|
||||
class="action-btn"
|
||||
>
|
||||
<v-icon size="16" start>mdi-door-open</v-icon>
|
||||
Klinik Ruang
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent-600"
|
||||
variant="outlined"
|
||||
@click="$emit('create-penunjang', item)"
|
||||
class="action-btn"
|
||||
>
|
||||
<v-icon size="16" start>mdi-clipboard-pulse</v-icon>
|
||||
Penunjang
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 10
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ title: 'Nama Pasien', value: 'namaPasien', sortable: true },
|
||||
{ title: 'No. RM', value: 'noRM', sortable: true },
|
||||
{ title: 'Alamat', value: 'alamat', sortable: true },
|
||||
{ title: 'Status', value: 'status', sortable: true },
|
||||
{ title: 'Buat Antrean', value: 'buatAntrean', sortable: false, width: '360px' },
|
||||
];
|
||||
|
||||
defineEmits(['create-klinik', 'create-ruang', 'create-penunjang']);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'Terdaftar': 'var(--color-success-600)',
|
||||
'Belum Terdaftar': 'var(--color-primary-600)',
|
||||
'Proses': 'var(--color-secondary-600)'
|
||||
};
|
||||
return colors[status] || 'var(--color-neutral-600)';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$neutral-900: #212121;
|
||||
$primary-600: #FFA532;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.patient-table-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $neutral-500;
|
||||
background: $neutral-100;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-neutral-300);
|
||||
font-size: 16px;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
color: $neutral-900;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: $neutral-900;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.patient-meta {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: $neutral-700;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 11px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 12px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
:deep(.data-table) {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
:deep(.data-table th) {
|
||||
font-family: $font-family-base;
|
||||
font-weight: $font-weight-semibold !important;
|
||||
color: $neutral-600 !important;
|
||||
}
|
||||
|
||||
:deep(.data-table td) {
|
||||
font-family: $font-family-base;
|
||||
border-bottom: 1px solid $neutral-400 !important;
|
||||
}
|
||||
|
||||
:deep(.data-table tbody tr:hover) {
|
||||
background: var(--color-neutral-300) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="day-selector">
|
||||
<v-chip
|
||||
v-for="hari in dayList"
|
||||
:key="hari.hari"
|
||||
class="day-chip"
|
||||
:class="{ 'day-chip-active': selectedDays.includes(hari.hari) }"
|
||||
label
|
||||
@click="toggleDay(hari.hari)"
|
||||
>
|
||||
<v-icon v-if="selectedDays.includes(hari.hari)" left size="16">mdi-check</v-icon>
|
||||
{{ hari.hari }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const dayList = ref([
|
||||
{ no: 1, hari: 'Senin' },
|
||||
{ no: 2, hari: 'Selasa' },
|
||||
{ no: 3, hari: 'Rabu' },
|
||||
{ no: 4, hari: 'Kamis' },
|
||||
{ no: 5, hari: 'Jum\'at' },
|
||||
]);
|
||||
|
||||
const selectedDays = ref(props.modelValue);
|
||||
|
||||
const toggleDay = (hari) => {
|
||||
const index = selectedDays.value.indexOf(hari);
|
||||
if (index > -1) {
|
||||
selectedDays.value.splice(index, 1);
|
||||
} else {
|
||||
selectedDays.value.push(hari);
|
||||
}
|
||||
emit('update:modelValue', selectedDays.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-800: #4D4D4D;
|
||||
$secondary-300: #DBEDFF;
|
||||
$secondary-600: #0671E0;
|
||||
$font-weight-medium: 500;
|
||||
|
||||
.day-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-medium;
|
||||
border: 1px solid $neutral-500;
|
||||
background-color: $neutral-100;
|
||||
color: $neutral-800;
|
||||
|
||||
&:hover {
|
||||
background-color: $secondary-300;
|
||||
}
|
||||
}
|
||||
|
||||
.day-chip-active {
|
||||
background-color: $secondary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
border-color: $secondary-600 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="900px" persistent scrollable>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header">
|
||||
<span class="headline-4">{{ isEdit ? 'Edit Klinik' : 'Tambah Klinik' }}</span>
|
||||
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<v-form ref="formRef">
|
||||
<!-- Informasi Dasar -->
|
||||
<FormFieldGroup title="Informasi Dasar">
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<v-text-field
|
||||
v-model="formModel.kode"
|
||||
label="Kode"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Kode harus diisi']"
|
||||
hide-details="auto"
|
||||
placeholder="AN"
|
||||
class="mb-3 input-field"
|
||||
/>
|
||||
<small class="caption-2">2 Huruf</small>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-text-field
|
||||
v-model="formModel.nama"
|
||||
label="Nama"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Nama harus diisi']"
|
||||
hide-details="auto"
|
||||
placeholder="Gigi dan Mulut"
|
||||
class="mb-3 input-field"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-text-field
|
||||
v-model.number="formModel.shift"
|
||||
label="Jumlah Shift"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Shift harus diisi', v => v > 0 || 'Minimal 1']"
|
||||
hide-details="auto"
|
||||
class="mb-3 input-field"
|
||||
@update:model-value="updateShiftCount"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-checkbox
|
||||
v-model="formModel.autoShift"
|
||||
label="Auto Shift"
|
||||
hide-details
|
||||
color="#0671E0"
|
||||
density="compact"
|
||||
class="checkbox-field"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
|
||||
<v-divider class="my-4 divider-section"/>
|
||||
|
||||
<!-- Konfigurasi Shift & Kuota -->
|
||||
<FormFieldGroup title="Konfigurasi Shift & Kuota" icon="mdi-clock-outline">
|
||||
<ShiftConfiguration
|
||||
:shifts="formModel.jamShiftList"
|
||||
@update:shifts="formModel.jamShiftList = $event"
|
||||
@remove-shift="removeShift"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
|
||||
<v-divider class="my-4 divider-section"/>
|
||||
|
||||
<!-- Jadwal Klinik -->
|
||||
<FormFieldGroup title="Jadwal Klinik" icon="mdi-calendar-check">
|
||||
<DaySelector v-model="formModel.jadwalKlinik" />
|
||||
</FormFieldGroup>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-actions class="dialog-actions">
|
||||
<v-spacer/>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
class="btn-cancel"
|
||||
@click="handleClose"
|
||||
>
|
||||
<v-icon left size="18">mdi-close</v-icon>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="flat"
|
||||
class="btn-submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<v-icon left size="18">mdi-content-save</v-icon>
|
||||
Simpan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
|
||||
import ShiftConfiguration from '@/components/master/ShiftConfiguration.vue';
|
||||
import DaySelector from '@/components/master/DaySelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
|
||||
|
||||
const formRef = ref(null);
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const formModel = computed({
|
||||
get: () => props.formData,
|
||||
set: (value) => emit('update:formData', value)
|
||||
});
|
||||
|
||||
const updateShiftCount = (newShiftCount) => {
|
||||
const currentCount = formModel.value.jamShiftList.length;
|
||||
|
||||
if (newShiftCount > currentCount) {
|
||||
for (let i = currentCount; i < newShiftCount; i++) {
|
||||
let defaultDari = '07:00', defaultSampai = '11:00';
|
||||
|
||||
if (i === 1) { defaultDari = '13:00'; defaultSampai = '16:00'; }
|
||||
else if (i === 2) { defaultDari = '18:00'; defaultSampai = '20:00'; }
|
||||
else if (i > 2) {
|
||||
const prevShift = formModel.value.jamShiftList[i - 1];
|
||||
const [prevHour] = prevShift.sampai.split(':');
|
||||
const nextHour = (parseInt(prevHour) + 1) % 24;
|
||||
defaultDari = `${String(nextHour).padStart(2, '0')}:00`;
|
||||
defaultSampai = `${String((nextHour + 4) % 24).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
formModel.value.jamShiftList.push({ dari: defaultDari, sampai: defaultSampai, kuota: 0 });
|
||||
}
|
||||
} else if (newShiftCount < currentCount && newShiftCount > 0) {
|
||||
formModel.value.jamShiftList = formModel.value.jamShiftList.slice(0, newShiftCount);
|
||||
}
|
||||
};
|
||||
|
||||
const removeShift = (index) => {
|
||||
if (formModel.value.jamShiftList.length > 1) {
|
||||
formModel.value.jamShiftList.splice(index, 1);
|
||||
formModel.value.shift = formModel.value.jamShiftList.length;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { valid } = await formRef.value.validate();
|
||||
if (valid) {
|
||||
emit('submit', formModel.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$neutral-800: #4D4D4D;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headline-4 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px !important;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 16px 24px;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.caption-2 {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: $neutral-700;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.divider-section {
|
||||
border-color: $neutral-400 !important;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border-color: $neutral-600 !important;
|
||||
color: $neutral-800 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: $secondary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(shift, index) in shifts" :key="index" class="shift-item">
|
||||
<v-row dense align="center">
|
||||
<v-col cols="2">
|
||||
<div class="shift-badge body-3">Shift {{ index + 1 }}</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="shift.dari"
|
||||
label="Mulai"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="input-field"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="shift.sampai"
|
||||
label="Selesai"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="input-field"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model.number="shift.kuota"
|
||||
label="Kuota"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
placeholder="0"
|
||||
class="input-field"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="1">
|
||||
<v-btn
|
||||
v-if="shifts.length > 1"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
class="btn-delete-shift"
|
||||
@click="removeShift(index)"
|
||||
>
|
||||
<v-icon size="18">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<div class="total-badge">
|
||||
<v-icon size="18" class="icon-success">mdi-sigma</v-icon>
|
||||
<span class="body-3 text-medium">Total Kuota:</span>
|
||||
<v-chip size="small" variant="flat" class="chip-success">
|
||||
{{ totalQuota }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
shifts: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:shifts', 'remove-shift']);
|
||||
|
||||
const totalQuota = computed(() => {
|
||||
return props.shifts.reduce((total, shift) => {
|
||||
return total + (parseInt(shift.kuota) || 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:shifts', props.shifts);
|
||||
};
|
||||
|
||||
const removeShift = (index) => {
|
||||
emit('remove-shift', index);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-700: #717171;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$success-200: #F1FBF8;
|
||||
$success-300: #84DFC1;
|
||||
$success-600: #009262;
|
||||
$danger-600: #E02B1D;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.shift-item {
|
||||
background: $neutral-300;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid $neutral-500;
|
||||
}
|
||||
|
||||
.shift-badge {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.text-medium {
|
||||
font-weight: $font-weight-medium !important;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.btn-delete-shift {
|
||||
color: $danger-600 !important;
|
||||
}
|
||||
|
||||
.total-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: $success-200;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid $success-300;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: $success-600 !important;
|
||||
}
|
||||
|
||||
.chip-success {
|
||||
background-color: $success-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-0 data-table"
|
||||
>
|
||||
<template #item.shift="{ item }">
|
||||
<v-chip size="small" class="chip-secondary">
|
||||
{{ item.shift }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.totalQuota="{ item }">
|
||||
<span class="body-2 text-semibold">{{ item.totalQuota }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.aksi="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
class="btn-edit mr-2"
|
||||
variant="flat"
|
||||
@click="$emit('edit', item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
class="btn-delete"
|
||||
variant="outlined"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-delete</v-icon>
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 10
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ title: 'No', value: 'no', sortable: true },
|
||||
{ title: 'Kode', value: 'kode', sortable: true },
|
||||
{ title: 'Nama Klinik', value: 'nama', sortable: true },
|
||||
{ title: 'Shift', value: 'shift', sortable: true },
|
||||
{ title: 'Total Kuota', value: 'totalQuota', sortable: true },
|
||||
{ title: 'Aksi', value: 'aksi', sortable: false },
|
||||
];
|
||||
|
||||
defineEmits(['edit', 'delete']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-800: #4D4D4D;
|
||||
$primary-600: #FFA532;
|
||||
$secondary-600: #0671E0;
|
||||
$danger-600: #E02B1D;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.data-table {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.chip-secondary {
|
||||
background-color: $secondary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.body-2 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.text-semibold {
|
||||
font-weight: $font-weight-semibold !important;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
border-color: $danger-600 !important;
|
||||
color: $danger-600 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header">
|
||||
<span class="headline-4">{{ isEdit ? 'Edit Loket' : 'Tambah Loket Baru' }}</span>
|
||||
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<v-form ref="formRef">
|
||||
<!-- Informasi Loket -->
|
||||
<FormFieldGroup title="Informasi Loket">
|
||||
<v-text-field
|
||||
v-model="formModel.namaLoket"
|
||||
label="Nama Loket"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Nama loket harus diisi']"
|
||||
hide-details="auto"
|
||||
class="mb-3 input-field"
|
||||
placeholder="Loket 1"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model.number="formModel.kuota"
|
||||
label="Kuota Bangku"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota minimal 1']"
|
||||
hide-details="auto"
|
||||
class="mb-3 input-field"
|
||||
placeholder="500"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
|
||||
<v-divider class="my-4 divider-section"/>
|
||||
|
||||
<!-- Konfigurasi -->
|
||||
<FormFieldGroup title="Konfigurasi">
|
||||
<v-select
|
||||
v-model="formModel.statusPelayanan"
|
||||
label="Status Pelayanan"
|
||||
:items="['RAWAT JALAN', 'RAWAT INAP']"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
|
||||
hide-details="auto"
|
||||
class="mb-3 input-field"
|
||||
/>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<v-select
|
||||
v-model="formModel.pembayaran"
|
||||
label="Pembayaran"
|
||||
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Pembayaran harus dipilih']"
|
||||
hide-details="auto"
|
||||
class="input-field"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-select
|
||||
v-model="formModel.keterangan"
|
||||
label="Keterangan"
|
||||
:items="['ONLINE', 'OFFLINE']"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Keterangan harus dipilih']"
|
||||
hide-details="auto"
|
||||
class="input-field"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</FormFieldGroup>
|
||||
|
||||
<v-divider class="my-4 divider-section"/>
|
||||
|
||||
<!-- Pelayanan -->
|
||||
<FormFieldGroup title="Pelayanan" icon="mdi-hospital-box">
|
||||
<v-select
|
||||
v-model="formModel.pelayanan"
|
||||
label="Pilih Pelayanan"
|
||||
:items="availableServices"
|
||||
item-title="nama"
|
||||
item-value="id"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:rules="[v => v.length > 0 || 'Pilih minimal 1 pelayanan']"
|
||||
hide-details="auto"
|
||||
class="input-field"
|
||||
>
|
||||
<template #chip="{ props, item }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
closable
|
||||
size="small"
|
||||
class="chip-primary"
|
||||
>
|
||||
{{ item.raw.nama }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #prepend>
|
||||
<v-chip size="x-small" class="chip-primary-small">{{ item.raw.id }}</v-chip>
|
||||
</template>
|
||||
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<!-- Selected Services Preview -->
|
||||
<div v-if="formModel.pelayanan.length > 0" class="selected-preview">
|
||||
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
|
||||
<small class="caption-2">{{ formModel.pelayanan.length }} pelayanan dipilih</small>
|
||||
</div>
|
||||
</FormFieldGroup>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-actions class="dialog-actions">
|
||||
<v-spacer/>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
class="btn-cancel"
|
||||
@click="handleClose"
|
||||
>
|
||||
<v-icon left size="18">mdi-close</v-icon>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="flat"
|
||||
class="btn-submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<v-icon left size="18">mdi-content-save</v-icon>
|
||||
Simpan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
availableServices: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
|
||||
|
||||
const formRef = ref(null);
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const formModel = computed({
|
||||
get: () => props.formData,
|
||||
set: (value) => emit('update:formData', value)
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { valid } = await formRef.value.validate();
|
||||
if (valid) {
|
||||
emit('submit', formModel.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$neutral-800: #4D4D4D;
|
||||
$primary-600: #FFA532;
|
||||
$primary-700: #FF9B1B;
|
||||
$success-200: #F1FBF8;
|
||||
$success-300: #84DFC1;
|
||||
$success-600: #009262;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headline-4 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px !important;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 16px 24px;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.divider-section {
|
||||
border-color: $neutral-400 !important;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
.caption-2 {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $neutral-700;
|
||||
}
|
||||
|
||||
.chip-primary {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.chip-primary-small {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.selected-preview {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: $success-200;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid $success-300;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: $success-600 !important;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border-color: $neutral-600 !important;
|
||||
color: $neutral-800 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-0 data-table"
|
||||
>
|
||||
<template #item.pelayanan="{ item }">
|
||||
<v-chip
|
||||
v-for="(serviceKode, idx) in item.pelayanan.slice(0, 2)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
class="mr-1 mb-1 chip-primary-outline"
|
||||
>
|
||||
{{ getKlinikName(serviceKode) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="item.pelayanan.length > 2"
|
||||
size="small"
|
||||
class="chip-neutral"
|
||||
>
|
||||
+{{ item.pelayanan.length - 2 }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.aksi="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="flat"
|
||||
class="btn-edit mr-2"
|
||||
@click="$emit('edit', item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="btn-delete"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-delete</v-icon>
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
getKlinikName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ title: "No", value: "no" },
|
||||
{ title: "Nama Loket", value: "namaLoket" },
|
||||
{ title: "Kuota", value: "kuota" },
|
||||
{ title: "Pelayanan", value: "pelayanan" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Keterangan", value: "keterangan" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
];
|
||||
|
||||
defineEmits(['edit', 'delete']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-600: #89939E;
|
||||
$primary-600: #FFA532;
|
||||
$danger-600: #E02B1D;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.data-table {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.chip-primary-outline {
|
||||
border: 1px solid $primary-600;
|
||||
background-color: transparent !important;
|
||||
color: $primary-600 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.chip-neutral {
|
||||
background-color: $neutral-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
border-color: $danger-600 !important;
|
||||
color: $danger-600 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header">
|
||||
<span class="headline-4">{{ isEdit ? 'Edit Klinik Ruang' : 'Tambah Klinik Ruang' }}</span>
|
||||
<v-btn icon variant="text" @click="handleClose" size="small" class="btn-close">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<v-form ref="formRef">
|
||||
<!-- Pilih Klinik -->
|
||||
<FormFieldGroup title="Pilih Klinik" icon="mdi-hospital-building">
|
||||
<v-autocomplete
|
||||
label="Kode Klinik"
|
||||
v-model="formModel.kodeKlinik"
|
||||
:items="klinikList"
|
||||
item-title="nama"
|
||||
item-value="kode"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Kode klinik harus dipilih']"
|
||||
hide-details="auto"
|
||||
placeholder="Pilih Klinik"
|
||||
@update:model-value="handleKlinikChange"
|
||||
class="mb-3 input-field"
|
||||
>
|
||||
<template v-slot:item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template v-slot:prepend>
|
||||
<v-chip size="x-small" class="chip-success-small">{{ item.raw.kode }}</v-chip>
|
||||
</template>
|
||||
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</FormFieldGroup>
|
||||
|
||||
<v-divider class="my-4 divider-section"/>
|
||||
|
||||
<!-- Daftar Ruangan -->
|
||||
<FormFieldGroup title="Daftar Ruangan" icon="mdi-door">
|
||||
<RoomListManager
|
||||
:rooms="formModel.ruangList"
|
||||
@update:rooms="formModel.ruangList = $event"
|
||||
@add-room="addRoom"
|
||||
@remove-room="removeRoom"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-actions class="dialog-actions">
|
||||
<v-spacer/>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
@click="handleClose"
|
||||
class="btn-cancel"
|
||||
>
|
||||
<v-icon left size="18">mdi-close</v-icon>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="flat"
|
||||
@click="handleSubmit"
|
||||
class="btn-submit"
|
||||
>
|
||||
<v-icon left size="18">mdi-content-save</v-icon>
|
||||
Simpan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
|
||||
import RoomListManager from '@/components/master/RoomListManager.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
klinikList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close', 'klinik-change']);
|
||||
|
||||
const formRef = ref(null);
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const formModel = computed({
|
||||
get: () => props.formData,
|
||||
set: (value) => emit('update:formData', value)
|
||||
});
|
||||
|
||||
const handleKlinikChange = (kode) => {
|
||||
emit('klinik-change', kode);
|
||||
};
|
||||
|
||||
const addRoom = () => {
|
||||
formModel.value.ruangList.push({
|
||||
nomorRuang: '',
|
||||
namaRuang: '',
|
||||
nomorScreen: ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeRoom = (index) => {
|
||||
if (formModel.value.ruangList.length > 1) {
|
||||
formModel.value.ruangList.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { valid } = await formRef.value.validate();
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
if (formModel.value.ruangList.length === 0) {
|
||||
// Emit validation error
|
||||
emit('validation-error', 'Tambahkan minimal 1 ruangan');
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', formModel.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$neutral-800: #4D4D4D;
|
||||
$success-600: #009262;
|
||||
$success-700: #1B6E53;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headline-4 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px !important;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 16px 24px;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.divider-section {
|
||||
border-color: $neutral-400 !important;
|
||||
}
|
||||
|
||||
.chip-success-small {
|
||||
background-color: $success-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border-color: $neutral-600 !important;
|
||||
color: $neutral-800 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: $success-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(ruang, index) in rooms" :key="index" class="ruang-item">
|
||||
<v-row dense align="center">
|
||||
<v-col cols="1">
|
||||
<div class="ruang-badge body-3">{{ index + 1 }}</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
label="No. Ruang"
|
||||
v-model="ruang.nomorRuang"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
placeholder="1"
|
||||
class="input-field"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="4">
|
||||
<v-text-field
|
||||
label="Nama Ruang"
|
||||
v-model="ruang.namaRuang"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
placeholder="Ruang Konsultasi"
|
||||
class="input-field"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
label="No. Screen"
|
||||
v-model="ruang.nomorScreen"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
placeholder="101"
|
||||
class="input-field"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="1">
|
||||
<v-btn
|
||||
v-if="rooms.length > 1"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="removeRoom(index)"
|
||||
class="btn-delete-ruang"
|
||||
>
|
||||
<v-icon size="18">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="addRoom"
|
||||
class="btn-add-ruang mt-2"
|
||||
>
|
||||
<v-icon left size="18">mdi-plus</v-icon>
|
||||
Tambah Ruang
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
rooms: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:rooms', 'add-room', 'remove-room']);
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:rooms', props.rooms);
|
||||
};
|
||||
|
||||
const addRoom = () => {
|
||||
emit('add-room');
|
||||
};
|
||||
|
||||
const removeRoom = (index) => {
|
||||
emit('remove-room', index);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-500: #ABBED1;
|
||||
$success-600: #009262;
|
||||
$success-700: #1B6E53;
|
||||
$danger-600: #E02B1D;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.ruang-item {
|
||||
background: $neutral-300;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid $neutral-500;
|
||||
}
|
||||
|
||||
.ruang-badge {
|
||||
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.btn-delete-ruang {
|
||||
color: $danger-600 !important;
|
||||
}
|
||||
|
||||
.btn-add-ruang {
|
||||
border-color: $success-600 !important;
|
||||
color: $success-600 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-0 data-table"
|
||||
>
|
||||
<template v-slot:item.namaKlinik="{ item }">
|
||||
<v-chip size="small" class="chip-success-outline">
|
||||
{{ item.kodeKlinik }}
|
||||
</v-chip>
|
||||
<span class="ml-2 body-3 text-medium">{{ item.namaKlinik }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
@click="$emit('edit', item)"
|
||||
class="btn-edit mr-2"
|
||||
variant="flat"
|
||||
>
|
||||
<v-icon size="16" left>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
@click="$emit('delete', item)"
|
||||
class="btn-delete"
|
||||
variant="outlined"
|
||||
>
|
||||
<v-icon size="16" left>mdi-delete</v-icon>
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 10
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ title: 'No', value: 'no', sortable: true },
|
||||
{ title: 'Nama Klinik', value: 'namaKlinik', sortable: true },
|
||||
{ title: 'Kode', value: 'kodeKlinik', sortable: true },
|
||||
{ title: 'Nama Ruang', value: 'namaRuang', sortable: true },
|
||||
{ title: 'Aksi', value: 'aksi', sortable: false },
|
||||
];
|
||||
|
||||
defineEmits(['edit', 'delete']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$success-600: #009262;
|
||||
$danger-600: #E02B1D;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.data-table {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.chip-success-outline {
|
||||
border: 1px solid $success-600;
|
||||
background-color: transparent !important;
|
||||
color: $success-600 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.text-medium {
|
||||
font-weight: $font-weight-medium !important;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: $success-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
border-color: $danger-600 !important;
|
||||
color: $danger-600 !important;
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<v-card class="current-patient-card" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="section-label mb-3">SEDANG DIPROSES</div>
|
||||
|
||||
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
|
||||
<div class="patient-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div>
|
||||
<div class="patient-info-text mb-3">
|
||||
<div>{{ patient.barcode }}</div>
|
||||
<div>{{ patient.klinik }} | {{ patient.pembayaran }}</div>
|
||||
</div>
|
||||
|
||||
<div class="action-grid">
|
||||
<v-btn class="text-white" color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')">
|
||||
<v-icon start size="20">mdi-check</v-icon>
|
||||
Selesai
|
||||
</v-btn>
|
||||
<v-btn class="text-white" color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')">
|
||||
<v-icon start size="20">mdi-clock-alert</v-icon>
|
||||
Terlambat
|
||||
</v-btn>
|
||||
<v-btn class="text-white" color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')">
|
||||
<v-icon start size="20">mdi-pause</v-icon>
|
||||
Pending
|
||||
</v-btn>
|
||||
<v-btn class="text-white" color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')">
|
||||
<v-icon start size="20">mdi-swap-horizontal</v-icon>
|
||||
{{ changeButtonText }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<v-icon size="48" color="grey-lighten-2">mdi-account-off-outline</v-icon>
|
||||
<div class="empty-text mb-4">Tidak ada pasien yang diproses</div>
|
||||
<v-btn
|
||||
block
|
||||
class="py-6 text-white"
|
||||
color="primary-600"
|
||||
size="large"
|
||||
:disabled="!hasNextQueue"
|
||||
@click="$emit('process-next')"
|
||||
>
|
||||
<v-icon start>mdi-play-circle</v-icon>
|
||||
Proses Antrian Berikutnya
|
||||
</v-btn>
|
||||
<div v-if="nextQueueInfo" class="next-queue-info mt-3">
|
||||
<div class="info-text">{{ nextQueueInfo }}</div>
|
||||
</div>
|
||||
<div v-else-if="!hasNextQueue" class="no-next-queue mt-3">
|
||||
<div class="info-text">Tidak ada antrian di loket</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'primary', // 'primary', 'secondary', or 'accent'
|
||||
validator: (value) => ['primary', 'secondary', 'accent'].includes(value)
|
||||
},
|
||||
changeButtonText: {
|
||||
type: String,
|
||||
default: 'Ubah Klinik'
|
||||
},
|
||||
hasNextQueue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
nextQueueInfo: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.current-patient-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-neutral-600);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.patient-details {
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.patient-details-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary-200) 0%, var(--color-primary-300) 100%);
|
||||
}
|
||||
|
||||
.patient-details-secondary {
|
||||
background: linear-gradient(135deg, var(--color-secondary-200) 0%, var(--color-secondary-300) 100%);
|
||||
}
|
||||
|
||||
.patient-details-accent {
|
||||
background: linear-gradient(135deg, var(--color-accent-200) 0%, var(--color-accent-300) 100%);
|
||||
}
|
||||
|
||||
.patient-number {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.patient-info-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.next-queue-info,
|
||||
.no-next-queue {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<v-tooltip
|
||||
:text="isClickable ? 'Klik untuk proses pasien' : ''"
|
||||
location="top"
|
||||
:disabled="!isClickable"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-card
|
||||
class="patient-card"
|
||||
elevation="2"
|
||||
:class="{ 'clickable-card': isClickable }"
|
||||
@click="handleCardClick"
|
||||
v-bind="isClickable ? tooltipProps : {}"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Header: Queue Number, Status & Fast Track Badge -->
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
|
||||
<v-icon
|
||||
v-if="patient.fastTrack === 'YA'"
|
||||
color="warning"
|
||||
size="20"
|
||||
class="fast-track-icon"
|
||||
>
|
||||
mdi-flash
|
||||
</v-icon>
|
||||
</div>
|
||||
<v-chip
|
||||
:color="getStatusColor(patient.status)"
|
||||
size="small"
|
||||
class="status-chip"
|
||||
>
|
||||
{{ getStatusLabel(patient.status) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Patient Info Grid - Simplified -->
|
||||
<div class="patient-info mt-3">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Jam Panggil:</span>
|
||||
<span class="info-value">{{ patient.jamPanggil }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Klinik:</span>
|
||||
<v-chip size="small" variant="outlined" class="klinik-chip">
|
||||
{{ patient.klinik }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Pembayaran:</span>
|
||||
<span class="info-value">{{ patient.pembayaran }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="!isClickable && patient.status === 'terlambat'" class="card-actions mt-3">
|
||||
<v-btn
|
||||
block
|
||||
color="success-600"
|
||||
variant="flat"
|
||||
size="small"
|
||||
class="text-white"
|
||||
@click.stop="$emit('action', patient, 'aktifkan')"
|
||||
>
|
||||
<v-icon start size="18">mdi-check-circle</v-icon>
|
||||
Aktifkan
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
patient: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const isClickable = computed(() => {
|
||||
return props.patient.status === 'diloket' || props.patient.status === 'pending';
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (isClickable.value) {
|
||||
emit('action', props.patient, 'proses');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
diloket: "var(--color-secondary-600)",
|
||||
diproses: "var(--color-primary-600)",
|
||||
terlambat: "var(--color-primary-600)",
|
||||
pending: "var(--color-danger-600)"
|
||||
};
|
||||
return colors[status] || "var(--color-neutral-600)";
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
diloket: "Di Loket",
|
||||
diproses: "Diproses",
|
||||
terlambat: "Terlambat",
|
||||
pending: "Pending"
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.patient-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
transition: all 0.2s ease;
|
||||
height: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.clickable-card {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-600);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.queue-number {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.fast-track-icon {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.payment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fast-track-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 500;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--color-neutral-900);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.klinik-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1.5px solid currentColor;
|
||||
}
|
||||
|
||||
.card-actions .v-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
height: 36px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<v-card class="queue-actions-card" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="section-label mb-3">PANGGIL ANTREAN</div>
|
||||
|
||||
<div class="quota-info mb-3">
|
||||
<div class="quota-item">
|
||||
<span class="quota-label">Kuota</span>
|
||||
<span class="quota-value">{{ totalQuota }}</span>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<span class="quota-label">Tersedia</span>
|
||||
<span class="quota-value quota-available">{{ availableQuota }}</span>
|
||||
</div>
|
||||
<div class="quota-item full-width">
|
||||
<v-progress-linear
|
||||
:model-value="quotaPercentage"
|
||||
color="success-600"
|
||||
height="6"
|
||||
rounded
|
||||
class="mt-1"
|
||||
/>
|
||||
<span class="quota-used">Terpakai: {{ usedQuota }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="call-buttons">
|
||||
<v-btn
|
||||
color="success-600"
|
||||
variant="outlined"
|
||||
@click="$emit('call', 1)"
|
||||
|
||||
>
|
||||
1
|
||||
</v-btn>
|
||||
<v-btn color="secondary-600" variant="outlined" @click="$emit('call', 5)">5</v-btn>
|
||||
<v-btn color="primary-600" variant="outlined" @click="$emit('call', 10)">10</v-btn>
|
||||
<v-btn color="danger-600" variant="outlined" @click="$emit('call', 20)">20</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
totalQuota: {
|
||||
type: Number,
|
||||
default: 150
|
||||
},
|
||||
usedQuota: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
hasNext: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const availableQuota = computed(() => props.totalQuota - props.usedQuota);
|
||||
const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100);
|
||||
|
||||
defineEmits(['call']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.queue-actions-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-neutral-600);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quota-item {
|
||||
background: var(--color-neutral-300);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quota-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quota-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.quota-available {
|
||||
color: var(--color-success-600);
|
||||
}
|
||||
|
||||
.quota-used {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.call-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.call-buttons .v-btn {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.call-buttons {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<v-card class="patient-data-container" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Header with Filters -->
|
||||
<div class="data-header mb-4">
|
||||
<div class="section-label">DATA PASIEN</div>
|
||||
|
||||
<div class="filters">
|
||||
<!-- Status Filter -->
|
||||
<v-chip-group v-model="selectedStatusModel" mandatory class="status-filter">
|
||||
<v-chip
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
:class="{ 'active-chip': selectedStatusModel === status.value }"
|
||||
>
|
||||
<v-icon v-if="status.icon" start size="16">{{ status.icon }}</v-icon>
|
||||
{{ status.label }} ({{ status.count }})
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
|
||||
<!-- Search Field -->
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
placeholder="Cari barcode, nomor antrian..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="search-field"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
<div class="advanced-filters mt-3">
|
||||
<v-select
|
||||
v-model="selectedKlinik"
|
||||
:items="klinikOptions"
|
||||
label="Filter Klinik"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="filter-select"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="20">mdi-hospital-building</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedPembayaran"
|
||||
:items="pembayaranOptions"
|
||||
label="Filter Pembayaran"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="filter-select"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="20">mdi-cash</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedShift"
|
||||
:items="shiftOptions"
|
||||
label="Filter Shift"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="filter-select"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="20">mdi-clock-outline</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
v-if="hasActiveFilters"
|
||||
variant="text"
|
||||
color="primary-600"
|
||||
size="small"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
<v-icon start size="18">mdi-filter-off</v-icon>
|
||||
Reset Filter
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Active Filter Tags -->
|
||||
<div v-if="hasActiveFilters" class="active-filters mt-2">
|
||||
<v-chip
|
||||
v-if="selectedKlinik"
|
||||
size="small"
|
||||
closable
|
||||
@click:close="selectedKlinik = null"
|
||||
>
|
||||
Klinik: {{ selectedKlinik }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="selectedPembayaran"
|
||||
size="small"
|
||||
closable
|
||||
@click:close="selectedPembayaran = null"
|
||||
>
|
||||
Pembayaran: {{ selectedPembayaran }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="selectedShift"
|
||||
size="small"
|
||||
closable
|
||||
@click:close="selectedShift = null"
|
||||
>
|
||||
{{ selectedShift }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="selectedFastTrackModel"
|
||||
size="small"
|
||||
closable
|
||||
@click:close="selectedFastTrackModel = null"
|
||||
>
|
||||
Fast Track: {{ selectedFastTrackModel }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div v-if="filteredAndSearchedItems.length > 0" class="results-info mb-3">
|
||||
<span class="results-text">
|
||||
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Patient Cards Grid -->
|
||||
<div v-if="filteredAndSearchedItems.length > 0" class="patient-grid">
|
||||
<PatientCard
|
||||
v-for="(patient, index) in paginatedItems"
|
||||
:key="`${patient.barcode}-${index}`"
|
||||
:patient="patient"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state">
|
||||
<v-icon size="64" color="neutral-500">mdi-account-search</v-icon>
|
||||
<div class="empty-text mt-3">
|
||||
{{ searchModel || hasActiveFilters ? 'Tidak ada pasien yang sesuai' : 'Tidak ada data pasien' }}
|
||||
</div>
|
||||
<div class="empty-subtext">
|
||||
{{ searchModel || hasActiveFilters ? 'Coba ubah filter atau kata kunci pencarian' : 'Data akan muncul ketika ada pasien yang terdaftar' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="filteredAndSearchedItems.length > itemsPerPage" class="pagination-container mt-4">
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
rounded="circle"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import PatientCard from './PatientCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedStatus: {
|
||||
type: String,
|
||||
default: 'all'
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
diLoketCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
diprosesCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
terlambatCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
pendingCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showDiproses: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 9
|
||||
},
|
||||
statusLabels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
all: 'Semua',
|
||||
diloket: 'Di Loket',
|
||||
diproses: 'Diproses',
|
||||
terlambat: 'Terlambat',
|
||||
pending: 'Pending'
|
||||
})
|
||||
},
|
||||
selectedFastTrack: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
fastTrackOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:selectedStatus', 'update:searchQuery', 'update:selectedFastTrack', 'action']);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const selectedKlinik = ref(null);
|
||||
const selectedPembayaran = ref(null);
|
||||
const selectedShift = ref(null);
|
||||
|
||||
const selectedFastTrackModel = computed({
|
||||
get: () => props.selectedFastTrack,
|
||||
set: (value) => {
|
||||
currentPage.value = 1;
|
||||
emit('update:selectedFastTrack', value);
|
||||
}
|
||||
});
|
||||
|
||||
const selectedStatusModel = computed({
|
||||
get: () => {
|
||||
// If Fast Track is selected, return 'fasttrack' as status
|
||||
if (selectedFastTrackModel.value === 'YA') {
|
||||
return 'fasttrack';
|
||||
}
|
||||
return props.selectedStatus;
|
||||
},
|
||||
set: (value) => {
|
||||
currentPage.value = 1;
|
||||
|
||||
// Handle Fast Track selection
|
||||
if (value === 'fasttrack') {
|
||||
selectedFastTrackModel.value = 'YA';
|
||||
// Emit 'all' as status since Fast Track is a separate filter
|
||||
emit('update:selectedStatus', 'all');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset Fast Track when other status is selected
|
||||
if (selectedFastTrackModel.value) {
|
||||
selectedFastTrackModel.value = null;
|
||||
}
|
||||
emit('update:selectedStatus', value);
|
||||
}
|
||||
});
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.searchQuery,
|
||||
set: (value) => {
|
||||
currentPage.value = 1;
|
||||
emit('update:searchQuery', value);
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
const baseOptions = [
|
||||
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
|
||||
{ value: 'diloket', label: props.statusLabels.diloket, count: props.diLoketCount }
|
||||
];
|
||||
|
||||
// Tampilkan "Diproses" hanya jika:
|
||||
// - label-nya didefinisikan, DAN
|
||||
// - komponen mengizinkan (showDiproses = true)
|
||||
if (props.showDiproses && props.statusLabels.diproses) {
|
||||
baseOptions.push({
|
||||
value: 'diproses',
|
||||
label: props.statusLabels.diproses,
|
||||
count: props.diprosesCount
|
||||
});
|
||||
}
|
||||
|
||||
baseOptions.push(
|
||||
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
|
||||
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
|
||||
);
|
||||
|
||||
// Add Fast Track as a status option
|
||||
if (fastTrackYaCount.value > 0) {
|
||||
baseOptions.push({
|
||||
value: 'fasttrack',
|
||||
label: 'Fast Track',
|
||||
count: fastTrackYaCount.value,
|
||||
icon: 'mdi-flash'
|
||||
});
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
});
|
||||
|
||||
// Generate filter options from items
|
||||
const klinikOptions = computed(() => {
|
||||
const kliniks = [...new Set(props.items.map(p => p.klinik))];
|
||||
return kliniks.sort();
|
||||
});
|
||||
|
||||
const pembayaranOptions = computed(() => {
|
||||
const pembayaran = [...new Set(props.items.map(p => p.pembayaran))];
|
||||
return pembayaran.sort();
|
||||
});
|
||||
|
||||
const shiftOptions = computed(() => {
|
||||
const shifts = [...new Set(props.items.map(p => p.shift))];
|
||||
return shifts.sort();
|
||||
});
|
||||
|
||||
// Count Fast Track "YA"
|
||||
const fastTrackYaCount = computed(() => {
|
||||
return props.items.filter(p => {
|
||||
const patientFastTrack = (p.fastTrack ?? "").toString().trim().toUpperCase();
|
||||
return patientFastTrack === 'YA';
|
||||
}).length;
|
||||
});
|
||||
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return !!(selectedKlinik.value || selectedPembayaran.value || selectedShift.value || selectedFastTrackModel.value);
|
||||
});
|
||||
|
||||
const clearAllFilters = () => {
|
||||
selectedKlinik.value = null;
|
||||
selectedPembayaran.value = null;
|
||||
selectedShift.value = null;
|
||||
selectedFastTrackModel.value = null;
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
// Handle Fast Track as a status category
|
||||
if (selectedStatusModel.value === 'fasttrack') {
|
||||
return props.items.filter(p => {
|
||||
const patientFastTrack = (p.fastTrack ?? "").toString().trim().toUpperCase();
|
||||
return patientFastTrack === 'YA';
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedStatusModel.value === 'all') return props.items;
|
||||
return props.items.filter(p => p.status === selectedStatusModel.value);
|
||||
});
|
||||
|
||||
const filteredAndSearchedItems = computed(() => {
|
||||
let result = filteredItems.value;
|
||||
|
||||
// Apply attribute filters
|
||||
if (selectedKlinik.value) {
|
||||
result = result.filter(p => p.klinik === selectedKlinik.value);
|
||||
}
|
||||
if (selectedPembayaran.value) {
|
||||
result = result.filter(p => p.pembayaran === selectedPembayaran.value);
|
||||
}
|
||||
if (selectedShift.value) {
|
||||
result = result.filter(p => p.shift === selectedShift.value);
|
||||
}
|
||||
// Fast Track filtering is now handled in filteredItems as a status category
|
||||
|
||||
// Apply search
|
||||
if (searchModel.value) {
|
||||
const searchLower = searchModel.value.toLowerCase();
|
||||
result = result.filter(patient =>
|
||||
patient.barcode?.toLowerCase().includes(searchLower) ||
|
||||
patient.noAntrian?.toLowerCase().includes(searchLower) ||
|
||||
patient.klinik?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(filteredAndSearchedItems.value.length / props.itemsPerPage)
|
||||
);
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * props.itemsPerPage;
|
||||
const end = start + props.itemsPerPage;
|
||||
return filteredAndSearchedItems.value.slice(start, end);
|
||||
});
|
||||
|
||||
const handleAction = (patient, action) => {
|
||||
emit('action', patient, action);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.patient-data-container {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-neutral-600);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.status-filter .v-chip {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-600);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
}
|
||||
|
||||
.status-filter .v-chip.active-chip {
|
||||
background: var(--color-secondary-600);
|
||||
color: var(--color-neutral-100);
|
||||
border-color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.advanced-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.results-info {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--color-primary-600);
|
||||
}
|
||||
|
||||
.results-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.patient-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-neutral-400);
|
||||
}
|
||||
|
||||
:deep(.v-pagination__item) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.advanced-filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.patient-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 961px) and (max-width: 1264px) {
|
||||
.patient-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1265px) {
|
||||
.patient-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
location="top end"
|
||||
offset="8"
|
||||
origin="bottom right"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<div
|
||||
v-bind="menuProps"
|
||||
class="d-flex align-center cursor-pointer pa-2 rounded-lg hover-bg"
|
||||
>
|
||||
<v-avatar size="40">
|
||||
<v-img
|
||||
:src="user.picture"
|
||||
:alt="`${user.name} Profile`"
|
||||
></v-img>
|
||||
<v-badge
|
||||
dot
|
||||
color="orange"
|
||||
location="bottom right"
|
||||
offset-x="2"
|
||||
offset-y="2"
|
||||
></v-badge>
|
||||
</v-avatar>
|
||||
|
||||
<div v-show="!rail" class="ml-3 flex-grow-1">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-btn v-show="!rail" icon size="small" variant="text">
|
||||
<v-icon size="20">mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-card width="300" elevation="8" rounded="lg">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-avatar size="48">
|
||||
<v-img :src="user.picture"></v-img>
|
||||
</v-avatar>
|
||||
<div class="ml-3">
|
||||
<div class="text-subtitle-1 font-weight-bold">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-list density="compact" class="pa-0">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-account-circle"
|
||||
class="px-2 rounded-lg"
|
||||
link
|
||||
@click="handleAction('profile')"
|
||||
>
|
||||
<v-list-item-title class="text-body-2">Profil</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-cog-outline"
|
||||
class="px-2 rounded-lg"
|
||||
link
|
||||
@click="handleAction('setting')"
|
||||
>
|
||||
<v-list-item-title class="text-body-2">Pengaturan</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-2"></v-divider>
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
class="px-2 rounded-lg text-red"
|
||||
link
|
||||
@click="signOut"
|
||||
:disabled="isLoggingOut"
|
||||
>
|
||||
<v-list-item-title class="text-body-2">
|
||||
{{ isLoggingOut ? 'Log out...' : 'Log out' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="text-caption text-grey mt-3 text-center">
|
||||
v2.5.18 · Terms & Conditions
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { navigateTo } from '#app';
|
||||
|
||||
// --- PROPS & EMITS ---
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
// Diubah menjadi required: false dengan default yang aman
|
||||
required: false,
|
||||
default: () => ({
|
||||
name: 'Guest',
|
||||
email: '[email protected]',
|
||||
picture: 'https://i.pravatar.cc/150?img=33'
|
||||
})
|
||||
},
|
||||
rail: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['logout']); // Hanya emit logout
|
||||
|
||||
// --- STATE ---
|
||||
const menu = ref(false);
|
||||
const isLoggingOut = ref(false);
|
||||
|
||||
// --- METHODS ---
|
||||
const signOut = () => {
|
||||
if (isLoggingOut.value) return;
|
||||
isLoggingOut.value = true;
|
||||
menu.value = false;
|
||||
|
||||
// Memicu event untuk ditangani oleh komponen induk (SideBar)
|
||||
emit('logout');
|
||||
// Biarkan parent yang mengurus redirect/state global
|
||||
setTimeout(() => isLoggingOut.value = false, 1000);
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
console.log(`[PopupSidebar] Action: ${action} triggered.`);
|
||||
switch(action) {
|
||||
case 'profile':
|
||||
navigateTo('/Profile/Profil');
|
||||
break;
|
||||
case 'setting':
|
||||
navigateTo('/Profile/Pengaturan');
|
||||
break;
|
||||
}
|
||||
menu.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tambahkan kembali style yang relevan untuk aktivator di sini */
|
||||
.hover-bg:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.text-red {
|
||||
color: rgb(244, 67, 54) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
location="top end"
|
||||
origin="bottom right"
|
||||
transition="scale-transition"
|
||||
open-on-hover
|
||||
>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-list-item
|
||||
v-if="!rail"
|
||||
v-bind="menuProps"
|
||||
:title="user?.name || user?.preferred_username || 'User'"
|
||||
:subtitle="user?.email || 'No email'"
|
||||
class="pa-3 ma-1 rounded-xl profile-activator"
|
||||
link
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="avatar-wrapper">
|
||||
<v-avatar size="44" class="avatar-glow">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<div class="status-dot"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-btn
|
||||
v-else
|
||||
icon
|
||||
v-bind="menuProps"
|
||||
size="large"
|
||||
variant="flat"
|
||||
class="avatar-btn"
|
||||
:title="user?.name || 'Profile'"
|
||||
>
|
||||
<div class="avatar-wrapper">
|
||||
<v-avatar size="44" class="avatar-glow">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<div class="status-dot"></div>
|
||||
</div>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="profile-card rounded-xl elevation-12" width="340">
|
||||
<!-- Decorative Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-pattern"></div>
|
||||
<div class="header-content pa-6 text-center">
|
||||
<div class="avatar-container mb-4">
|
||||
<v-avatar size="90" class="profile-avatar">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<div class="status-badge">
|
||||
<v-icon size="12" color="white">mdi-check</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-h6 font-weight-bold text-white mb-1">
|
||||
{{ user?.name || user?.preferred_username || 'User' }}
|
||||
</h3>
|
||||
<p class="text-body-2 text-white opacity-90 mb-2">
|
||||
{{ user?.email || 'No email' }}
|
||||
</p>
|
||||
<v-chip
|
||||
size="small"
|
||||
class="glass-chip"
|
||||
prepend-icon="mdi-identifier"
|
||||
>
|
||||
{{ user?.id?.substring(0, 10) }}...
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<v-card-text class="pa-4">
|
||||
<div class="menu-grid">
|
||||
<div
|
||||
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
|
||||
@click="handleAction('profile')"
|
||||
>
|
||||
<div class="tile-icon-wrapper mb-2 mx-auto">
|
||||
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon>
|
||||
</div>
|
||||
<div class="text-caption font-weight-medium">Profil</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
|
||||
@click="handleAction('account')"
|
||||
>
|
||||
<div class="tile-icon-wrapper mb-2 mx-auto">
|
||||
<v-icon size="24" color="orange-darken-2">mdi-cog-outline</v-icon>
|
||||
</div>
|
||||
<div class="text-caption font-weight-medium">Pengaturan</div>
|
||||
</div>
|
||||
|
||||
<!-- <div
|
||||
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
|
||||
@click="handleAction('darkMode')"
|
||||
>
|
||||
<div class="tile-icon-wrapper mb-2 mx-auto">
|
||||
<v-icon size="24" :color="darkMode ? 'deep-purple-darken-2' : 'amber-darken-2'">
|
||||
{{ darkMode ? 'mdi-moon-waning-crescent' : 'mdi-white-balance-sunny' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
<div class="text-caption font-weight-medium">
|
||||
{{ darkMode ? 'Gelap' : 'Terang' }}
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg logout-btn"
|
||||
variant="flat"
|
||||
color="error"
|
||||
prepend-icon="mdi-logout"
|
||||
@click="signOut"
|
||||
:disabled="isLoggingOut"
|
||||
:loading="isLoggingOut"
|
||||
>
|
||||
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { navigateTo } from '#app';
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
rail: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const menu = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const isLoggingOut = ref(false);
|
||||
const emit = defineEmits(['logout']);
|
||||
|
||||
const signOut = async () => {
|
||||
if (isLoggingOut.value) return;
|
||||
isLoggingOut.value = true;
|
||||
menu.value = false;
|
||||
|
||||
try {
|
||||
emit('logout');
|
||||
} finally {
|
||||
isLoggingOut.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
switch(action) {
|
||||
case 'account':
|
||||
navigateTo('/Profile/Pengaturan')
|
||||
break;
|
||||
case 'profile':
|
||||
navigateTo('/Profile/Profil')
|
||||
break;
|
||||
case 'darkMode':
|
||||
darkMode.value = !darkMode.value;
|
||||
return;
|
||||
}
|
||||
|
||||
menu.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-activator {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
|
||||
border: 1px solid rgba(25, 118, 210, 0.2);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.profile-activator:hover {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
|
||||
border-color: rgba(25, 118, 210, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-glow {
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-activator:hover .chevron-icon {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.glass-chip {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-tile {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
|
||||
border: 1px solid rgba(25, 118, 210, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-tile:hover {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
|
||||
border-color: rgba(25, 118, 210, 0.3);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tile-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opacity-90 {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
:model-value="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
app
|
||||
class="bg-white d-flex flex-column"
|
||||
@update:model-value="emit('update:drawer', $event)"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<v-list-item class="py-4 px-2" :class="{ 'text-center': rail }">
|
||||
<div class="d-flex align-center" :class="{ 'justify-center': rail }">
|
||||
<img
|
||||
src="/Antrean Logo.png"
|
||||
alt="Antrean Logo"
|
||||
style="width: 72px; height: 72px; object-fit: contain"
|
||||
/>
|
||||
<v-list-item-title
|
||||
v-if="!rail"
|
||||
class="ml-2 text-h6 font-weight-bold text-blue-grey-darken-4"
|
||||
>
|
||||
<span class="text-orange-darken-2">Antrean</span> RSSA
|
||||
</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-list
|
||||
density="compact"
|
||||
nav
|
||||
class="mt-2 flex-grow-1"
|
||||
v-model:opened="openedGroups"
|
||||
>
|
||||
<template v-for="item in items" :key="item.name">
|
||||
<v-list-group v-if="item.children" :value="item.name" :disabled="rail">
|
||||
<template v-slot:activator="{ props: groupProps }">
|
||||
<v-list-item
|
||||
v-bind="groupProps"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
color="orange-darken-2"
|
||||
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
|
||||
></v-list-item>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.name"
|
||||
:to="child.path"
|
||||
:title="child.name"
|
||||
:prepend-icon="child.icon"
|
||||
link
|
||||
class="pl-8"
|
||||
color="orange-darken-2"
|
||||
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
|
||||
></v-list-item>
|
||||
</v-list-group>
|
||||
|
||||
<v-tooltip
|
||||
v-else
|
||||
:disabled="!rail"
|
||||
open-on-hover
|
||||
location="end"
|
||||
:text="item.name"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-list-item
|
||||
v-bind="tooltipProps"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
:to="item.path"
|
||||
link
|
||||
color="orange-darken-2"
|
||||
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
|
||||
></v-list-item>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-list v-if="user" nav density="compact" class="pa-2 flex-shrink-0">
|
||||
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
|
||||
</v-list>
|
||||
<v-list v-else nav density="compact" class="flex-shrink-0">
|
||||
<v-list-item
|
||||
@click="redirectToLogin"
|
||||
prepend-icon="mdi-login"
|
||||
title="Login"
|
||||
link
|
||||
color="blue-grey-darken-4"
|
||||
></v-list-item>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits, onMounted, ref, watch } from "vue";
|
||||
import { navigateTo } from "#app";
|
||||
import ProfilePopup from "./ProfilePopup.vue";
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
|
||||
const { user, logout, checkAuth } = useAuth();
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as () => NavItem[],
|
||||
required: true,
|
||||
},
|
||||
rail: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
drawer: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:drawer", "toggle-rail"]);
|
||||
|
||||
const openedGroups = ref<string[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.rail,
|
||||
(newRailState) => {
|
||||
if (newRailState === true) {
|
||||
openedGroups.value = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const isHovering = ref(false);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.rail) {
|
||||
isHovering.value = true;
|
||||
emit("toggle-rail");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (isHovering.value) {
|
||||
isHovering.value = false;
|
||||
emit("toggle-rail");
|
||||
}
|
||||
};
|
||||
|
||||
// --- AUTH LOGIC (Kept the same) ---
|
||||
|
||||
const handleLogout = async () => {
|
||||
console.log("🚪 SideBar logout initiated...");
|
||||
try {
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error("❌ SideBar logout error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToLogin = () => {
|
||||
navigateTo("/LoginPage");
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await checkAuth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No specific overrides needed */
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<v-card-text class="pa-0 bg-white">
|
||||
<v-list lines="two" class="pa-0">
|
||||
<v-list-item
|
||||
v-for="patient in patients"
|
||||
:key="patient.rm"
|
||||
class="patient-item"
|
||||
:class="{ 'verified-item': patient.status === 'Terverifikasi' }"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar
|
||||
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
|
||||
size="64"
|
||||
class="patient-avatar"
|
||||
>
|
||||
<v-icon size="36" color="white">
|
||||
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="patient-name">
|
||||
{{ patient.nama }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="mt-2">
|
||||
<v-row dense class="patient-info">
|
||||
<v-col cols="12" sm="3" md="2" class="py-1">
|
||||
<v-chip size="default" color="primary-200" class="chip-rm" variant="flat">
|
||||
<v-icon start size="18" color="secondary-600">mdi-file-document</v-icon>
|
||||
<span class="chip-rm-text">{{ patient.rm }}</span>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="5" md="6" class="py-1 info-item">
|
||||
<v-icon size="18" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
|
||||
<span>{{ patient.alamat }}</span>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4" md="4" class="py-1 info-item">
|
||||
<v-icon size="18" class="mr-2" color="neutral-600">mdi-phone</v-icon>
|
||||
<span>{{ patient.telepon || 'Belum diisi' }}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="patient.status === 'Belum Terverifikasi'"
|
||||
color="primary-600"
|
||||
size="x-large"
|
||||
@click="$emit('verify', patient)"
|
||||
prepend-icon="mdi-qrcode-scan"
|
||||
variant="flat"
|
||||
class="action-btn"
|
||||
rounded="xl"
|
||||
>
|
||||
VERIFIKASI
|
||||
</v-btn>
|
||||
|
||||
<v-chip
|
||||
v-else
|
||||
color="secondary-600"
|
||||
size="x-large"
|
||||
variant="flat"
|
||||
class="verified-chip"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-icon start size="24">mdi-shield-check</v-icon>
|
||||
VERIFIED
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="patients.length === 0">
|
||||
<v-list-item-title class="empty-state">
|
||||
{{ emptyMessage }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
patients: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: 'Tidak ada data pasien'
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['verify']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$neutral-900: #212121;
|
||||
$primary-100: #FFE8CC;
|
||||
$primary-200: #FFDCAF;
|
||||
$primary-600: #FFA532;
|
||||
$secondary-200: #EDF5FF;
|
||||
$secondary-300: #DBEDFF;
|
||||
$secondary-400: #B3D9FF;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
$font-weight-extra-bold: 800;
|
||||
|
||||
.patient-item {
|
||||
border-bottom: 1px solid $neutral-400;
|
||||
padding: 24px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.patient-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
background: $primary-600;
|
||||
transform: scaleY(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.patient-item:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.patient-item:hover {
|
||||
background: $primary-100 !important;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.verified-item {
|
||||
background: $secondary-200 !important;
|
||||
}
|
||||
|
||||
.verified-item::before {
|
||||
background: $secondary-600 !important;
|
||||
}
|
||||
|
||||
.verified-item:hover {
|
||||
background: $secondary-300 !important;
|
||||
}
|
||||
|
||||
.patient-avatar {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 3px solid $neutral-100;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: 24px;
|
||||
font-weight: $font-weight-extra-bold;
|
||||
color: $neutral-900;
|
||||
margin-bottom: 8px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
font-size: 14px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.chip-rm {
|
||||
border: 1px solid $secondary-400;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.chip-rm-text {
|
||||
color: $secondary-700;
|
||||
font-weight: $font-weight-bold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
color: $neutral-700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
text-transform: none;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: $font-weight-extra-bold;
|
||||
font-size: 16px;
|
||||
color: $neutral-100;
|
||||
padding: 12px 32px;
|
||||
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.25);
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.verified-chip {
|
||||
font-weight: $font-weight-extra-bold;
|
||||
font-size: 16px;
|
||||
color: $neutral-100;
|
||||
padding: 12px 24px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
font-size: 18px;
|
||||
color: $neutral-600;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.patient-item {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 14px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.patient-avatar {
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
max-width="600"
|
||||
transition="dialog-bottom-transition"
|
||||
scrollable
|
||||
>
|
||||
<v-card class="modal-card">
|
||||
<!-- Modal Header -->
|
||||
<div class="modal-header">
|
||||
<v-icon size="72" color="white" class="mb-3">mdi-qrcode-scan</v-icon>
|
||||
<h2 class="modal-title">Aktivasi Akun</h2>
|
||||
<p class="modal-subtitle">{{ patient.nama }}</p>
|
||||
<v-chip color="white" class="modal-chip" variant="flat" size="default">
|
||||
<span class="modal-chip-text">RM: {{ patient.rm }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<v-card-text class="pa-7">
|
||||
<v-text-field
|
||||
:model-value="phoneNumber"
|
||||
@update:model-value="$emit('update:phoneNumber', $event)"
|
||||
:disabled="qrGenerated"
|
||||
label="Nomor Telepon Pasien"
|
||||
placeholder="08xxxxxxxxxx"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:color="qrGenerated ? 'grey' : 'primary-600'"
|
||||
:rules="[v => v.length >= 8 || 'Min. 8 digit']"
|
||||
prepend-inner-icon="mdi-phone"
|
||||
class="mb-3 phone-field"
|
||||
base-color="grey-darken-1"
|
||||
/>
|
||||
|
||||
<!-- Generate Button -->
|
||||
<v-btn
|
||||
v-if="!qrGenerated"
|
||||
:disabled="!isPhoneValid"
|
||||
color="secondary-600"
|
||||
block
|
||||
size="x-large"
|
||||
class="generate-btn"
|
||||
@click="$emit('generate')"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-icon left size="32">mdi-qrcode-plus</v-icon>
|
||||
Generate QR Code
|
||||
</v-btn>
|
||||
|
||||
<!-- QR Code Container -->
|
||||
<div v-else class="qr-container">
|
||||
<div class="pulse-icon">
|
||||
<v-icon color="secondary-600" :size="isMobile ? 48 : 64">
|
||||
mdi-cellphone-check
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<h3 class="qr-title">Pindai QR Code</h3>
|
||||
<p class="qr-subtitle">Arahkan kamera smartphone ke QR code</p>
|
||||
|
||||
<div class="d-flex justify-center mb-3 mb-sm-4">
|
||||
<div class="qr-frame">
|
||||
<slot name="qr-code" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<v-card-actions v-if="qrGenerated" class="modal-actions">
|
||||
<v-btn
|
||||
color="secondary-600"
|
||||
variant="outlined"
|
||||
@click="$emit('reload')"
|
||||
prepend-icon="mdi-reload"
|
||||
size="x-large"
|
||||
class="modal-action-btn"
|
||||
rounded="xl"
|
||||
>
|
||||
Reload QR
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary-600"
|
||||
variant="flat"
|
||||
@click="$emit('complete')"
|
||||
prepend-icon="mdi-check-circle"
|
||||
size="x-large"
|
||||
class="modal-action-btn-primary"
|
||||
rounded="xl"
|
||||
>
|
||||
Selesai
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
<v-card-actions v-else class="modal-actions">
|
||||
<v-btn
|
||||
color="neutral-600"
|
||||
variant="text"
|
||||
@click="$emit('close')"
|
||||
size="x-large"
|
||||
block
|
||||
rounded="xl"
|
||||
class="modal-cancel-btn"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
phoneNumber: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
qrGenerated: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:phoneNumber',
|
||||
'generate',
|
||||
'reload',
|
||||
'complete',
|
||||
'close'
|
||||
]);
|
||||
|
||||
const isPhoneValid = computed(() => props.phoneNumber.length >= 8);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$primary-600: #FFA532;
|
||||
$secondary-200: #EDF5FF;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
$font-weight-extra-bold: 800;
|
||||
$font-weight-black: 900;
|
||||
|
||||
.modal-card {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
box-shadow: 0 4px 12px rgba(6, 99, 199, 0.2);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32px;
|
||||
font-weight: $font-weight-black;
|
||||
color: $neutral-100;
|
||||
margin: 8px 0;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 20px;
|
||||
color: $neutral-100;
|
||||
opacity: 0.95;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.modal-chip {
|
||||
margin-top: 8px;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.modal-chip-text {
|
||||
color: $primary-600;
|
||||
font-weight: $font-weight-bold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.phone-field {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
margin-top: 12px;
|
||||
font-weight: $font-weight-extra-bold;
|
||||
font-size: 18px;
|
||||
color: $neutral-100;
|
||||
box-shadow: 0 2px 8px rgba(6, 99, 199, 0.25);
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
background: $secondary-200;
|
||||
border: 3px solid $secondary-600;
|
||||
border-radius: 16px;
|
||||
padding: 28px 16px;
|
||||
margin-top: 24px;
|
||||
box-shadow: 0 6px 20px rgba(6, 99, 199, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.qr-container {
|
||||
padding: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.qr-title {
|
||||
font-size: 24px;
|
||||
font-weight: $font-weight-black;
|
||||
color: $secondary-700;
|
||||
margin-bottom: 8px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.qr-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-subtitle {
|
||||
font-size: 13px;
|
||||
color: $neutral-700;
|
||||
margin-bottom: 16px;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.qr-subtitle {
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-frame {
|
||||
padding: 16px;
|
||||
background: $neutral-100;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
display: inline-block;
|
||||
border: 3px solid $secondary-600;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.qr-frame {
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 4px solid $secondary-600;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 28px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.modal-action-btn,
|
||||
.modal-action-btn-primary {
|
||||
font-weight: $font-weight-bold;
|
||||
font-size: 14px;
|
||||
text-transform: none;
|
||||
flex-grow: 1;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.modal-action-btn-primary {
|
||||
color: $neutral-100;
|
||||
font-weight: $font-weight-extra-bold;
|
||||
}
|
||||
|
||||
.modal-cancel-btn {
|
||||
font-weight: $font-weight-bold;
|
||||
font-size: 14px;
|
||||
text-transform: none;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<v-row class="search-section">
|
||||
<v-col cols="12" sm="6" class="py-2">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
density="comfortable"
|
||||
label="Cari pasien berdasarkan nama atau RM..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
single-line
|
||||
color="primary-600"
|
||||
bg-color="white"
|
||||
class="search-field"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" class="py-2">
|
||||
<v-tabs
|
||||
:model-value="selectedTab"
|
||||
@update:model-value="$emit('update:selectedTab', $event)"
|
||||
color="primary-600"
|
||||
align-tabs="end"
|
||||
class="filter-tabs"
|
||||
slider-color="primary-600"
|
||||
>
|
||||
<v-tab :value="0" class="tab-item">
|
||||
SEMUA
|
||||
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
|
||||
{{ allCount }}
|
||||
</v-chip>
|
||||
</v-tab>
|
||||
|
||||
<v-tab :value="1" class="tab-item">
|
||||
PENDING
|
||||
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
|
||||
{{ pendingCount }}
|
||||
</v-chip>
|
||||
</v-tab>
|
||||
|
||||
<v-tab :value="2" class="tab-item">
|
||||
VERIFIED
|
||||
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
|
||||
{{ verifiedCount }}
|
||||
</v-chip>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
selectedTab: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
allCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
pendingCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
verifiedCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['update:searchQuery', 'update:selectedTab']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-300: #F5F7FA;
|
||||
$primary-600: #FFA532;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
.search-section {
|
||||
background: $neutral-300;
|
||||
border-bottom: 1px solid $neutral-500;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
border-radius: 8px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
background: transparent;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
font-weight: $font-weight-bold;
|
||||
font-size: 14px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
color: #000 !important;
|
||||
font-weight: $font-weight-bold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
// composables/useAuth.ts - Enhanced version with better error handling
|
||||
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
|
||||
|
||||
export const useAuth = () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
const checkAuth = async (): Promise<User | null> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
clearError()
|
||||
|
||||
const response = await $fetch<SessionResponse>('/api/auth/session')
|
||||
|
||||
if (response.success === false && response.error) {
|
||||
error.value = response.error
|
||||
user.value = null
|
||||
return null
|
||||
}
|
||||
|
||||
user.value = response.user
|
||||
return response.user
|
||||
} catch (fetchError: any) {
|
||||
console.error('Session check failed:', fetchError)
|
||||
error.value = 'Failed to check authentication status'
|
||||
user.value = null
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (): Promise<void> => {
|
||||
try {
|
||||
clearError()
|
||||
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response?.success && response?.data?.authUrl) {
|
||||
console.log('🔗 Redirecting to Keycloak login...')
|
||||
window.location.href = response.data.authUrl
|
||||
} else {
|
||||
const errorMsg = response?.error || 'Failed to get authorization URL'
|
||||
error.value = errorMsg
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
} catch (loginError: any) {
|
||||
console.error('❌ Login error:', loginError)
|
||||
error.value = loginError.message || 'Login failed'
|
||||
throw loginError
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
clearError()
|
||||
console.log('🚪 Starting logout process...')
|
||||
|
||||
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
// Clear user immediately regardless of response
|
||||
user.value = null
|
||||
|
||||
if (response?.success && response?.logoutUrl) {
|
||||
console.log('🔗 Redirecting to Keycloak logout...')
|
||||
window.location.href = response.logoutUrl
|
||||
} else {
|
||||
const warningMsg = response?.error || response?.message || 'No logout URL received'
|
||||
console.warn('⚠️', warningMsg)
|
||||
error.value = warningMsg
|
||||
await navigateTo('/LoginPage')
|
||||
}
|
||||
} catch (logoutError: any) {
|
||||
console.error('❌ Logout error:', logoutError)
|
||||
error.value = logoutError.message || 'Logout failed'
|
||||
user.value = null
|
||||
await navigateTo('/LoginPage')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to refresh user data
|
||||
const refreshUser = async (): Promise<boolean> => {
|
||||
const userData = await checkAuth()
|
||||
return !!userData
|
||||
}
|
||||
|
||||
// Helper function to check if user has specific role
|
||||
const hasRole = (role: string): boolean => {
|
||||
if (!user.value) return false
|
||||
|
||||
// Check in roles array
|
||||
if (user.value.roles?.includes(role)) return true
|
||||
|
||||
// Check in realm_access.roles
|
||||
if (user.value.realm_access?.roles?.includes(role)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper function to check if user has any of the specified roles
|
||||
const hasAnyRole = (roles: string[]): boolean => {
|
||||
return roles.some(role => hasRole(role))
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user: readonly(user),
|
||||
isAuthenticated,
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
|
||||
// Actions
|
||||
checkAuth,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
clearError,
|
||||
|
||||
// Utilities
|
||||
hasRole,
|
||||
hasAnyRole
|
||||
}
|
||||
}
|
||||
@@ -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<CheckInResult | null>(null)
|
||||
|
||||
// Perform check-in
|
||||
const performCheckIn = async (data: string, method: string = 'QR Scan'): Promise<boolean> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<CheckInHistoryItem[]>([])
|
||||
const scannedQRHistory = ref<ScannedQRHistoryItem[]>([])
|
||||
const historySearch = ref('')
|
||||
const historyStatusFilter = ref<HistoryStatus | ''>('')
|
||||
const historyDateFilter = ref<string>('')
|
||||
|
||||
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 }>,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// /composables/usePermissions.ts
|
||||
export const usePermission = () => {
|
||||
/**
|
||||
* Extract group and role from path
|
||||
* Example: "/Instalasi STIM/Devops/Superadmin"
|
||||
* → { group: "STIM", role: "superadmin" }
|
||||
*/
|
||||
const parsePath = (path: string) => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
const group = parts[1] || ""; // "STIM"
|
||||
const role = parts.at(-1)?.toLowerCase() || ""; // "superadmin"
|
||||
return { group, role };
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch permission from backend API
|
||||
*/
|
||||
const fetchPermission = async (path: string) => {
|
||||
const { group, role } = parsePath(path);
|
||||
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`;
|
||||
|
||||
const { data, error } = await useFetch(url, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (error.value) {
|
||||
console.error("❌ Gagal mengambil permission:", error.value);
|
||||
throw error.value;
|
||||
}
|
||||
|
||||
console.log("✅ Permission data:", data.value);
|
||||
return data.value;
|
||||
};
|
||||
|
||||
return { parsePath, fetchPermission };
|
||||
};
|
||||
@@ -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<CheckInStatus>('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,
|
||||
}
|
||||
}
|
||||
@@ -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<Set<string>>(new Set())
|
||||
|
||||
// Detect mobile device
|
||||
const isMobile = ref(false)
|
||||
if (typeof window !== 'undefined') {
|
||||
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
(window.innerWidth <= 768)
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// composables/useQueue.js
|
||||
import { ref, computed } from "vue";
|
||||
import { useQueueStore } from "../stores/queueStore";
|
||||
|
||||
export const useQueue = (adminType = "loket") => {
|
||||
const queueStore = useQueueStore();
|
||||
|
||||
// Local state
|
||||
const snackbar = ref(false);
|
||||
const snackbarText = ref("");
|
||||
const snackbarColor = ref("success");
|
||||
|
||||
// Dialog states
|
||||
const showKlinikDialog = ref(false);
|
||||
const showPenunjangDialog = ref(false);
|
||||
const showChangeKlinikDialog = ref(false);
|
||||
const klinikSearch = ref("");
|
||||
const penunjangSearch = ref("");
|
||||
const changeKlinikSearch = ref("");
|
||||
const selectedPatientForPenunjang = ref(null);
|
||||
|
||||
// FIXED: Get stage patients once, then derive others from it
|
||||
const stagePatients = computed(() => {
|
||||
// Don't use .value here - getPatientsByStage returns a computed already
|
||||
const patients = queueStore.getPatientsByStage(adminType);
|
||||
return patients.value;
|
||||
});
|
||||
|
||||
// Computed from store - filtered by stage
|
||||
const currentProcessingPatient = computed(() => {
|
||||
return queueStore.currentProcessingPatient[adminType];
|
||||
});
|
||||
|
||||
// Derive from stagePatients - ADD DEBUG LOGS
|
||||
const diLoketPatients = computed(() => {
|
||||
const patients = stagePatients.value.diLoket || [];
|
||||
console.log('🔍 useQueue - diLoketPatients:', patients.length);
|
||||
if (patients.length > 0) {
|
||||
console.log('🔍 First diLoket patient:', patients[0]);
|
||||
console.log('🔍 First diLoket patient fastTrack:', patients[0].fastTrack);
|
||||
}
|
||||
return patients;
|
||||
});
|
||||
|
||||
const terlambatPatients = computed(() => {
|
||||
const patients = stagePatients.value.terlambat || [];
|
||||
console.log('🔍 useQueue - terlambatPatients:', patients.length);
|
||||
return patients;
|
||||
});
|
||||
|
||||
const pendingPatients = computed(() => {
|
||||
const patients = stagePatients.value.pending || [];
|
||||
console.log('🔍 useQueue - pendingPatients:', patients.length);
|
||||
return patients;
|
||||
});
|
||||
|
||||
const waitingPatients = computed(() => stagePatients.value.waiting || []);
|
||||
|
||||
const nextPatient = computed(() => {
|
||||
return waitingPatients.value[0] || null;
|
||||
});
|
||||
|
||||
// Total pasien hanya untuk stage admin ini
|
||||
const totalPasien = computed(() => {
|
||||
const total = queueStore.getTotalPasienByStage(adminType);
|
||||
return total.value;
|
||||
});
|
||||
|
||||
const quotaUsed = computed(() => queueStore.quotaUsed);
|
||||
|
||||
// Expose dev helper to refresh seed data
|
||||
const resetPatients = () => queueStore.resetPatients();
|
||||
|
||||
// Filtered lists
|
||||
const filteredKliniks = computed(() => {
|
||||
if (!klinikSearch.value) return queueStore.kliniks;
|
||||
return queueStore.kliniks.filter((k) =>
|
||||
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredPenunjangs = computed(() => {
|
||||
if (!penunjangSearch.value) return queueStore.penunjangs;
|
||||
return queueStore.penunjangs.filter((p) =>
|
||||
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredChangeKliniks = computed(() => {
|
||||
if (!changeKlinikSearch.value) return queueStore.kliniks;
|
||||
return queueStore.kliniks.filter((k) =>
|
||||
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (text, color = "success") => {
|
||||
snackbarText.value = text;
|
||||
snackbarColor.value = color;
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const callNext = () => {
|
||||
const result = queueStore.callNext(adminType);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const callMultiplePatients = (count) => {
|
||||
const result = queueStore.callMultiplePatients(count, adminType);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const processPatient = (patient, action) => {
|
||||
const result = queueStore.processPatient(patient, action, adminType);
|
||||
|
||||
let color = "success";
|
||||
if (action === "terlambat") color = "warning";
|
||||
else if (action === "pending") color = "info";
|
||||
|
||||
showSnackbar(result.message, color);
|
||||
};
|
||||
|
||||
const selectKlinik = (klinik) => {
|
||||
const result = queueStore.createAntreanKlinik(klinik, currentProcessingPatient.value, adminType);
|
||||
showSnackbar(result.message, "success");
|
||||
showKlinikDialog.value = false;
|
||||
};
|
||||
|
||||
const selectPenunjang = (penunjang) => {
|
||||
const result = queueStore.createAntreanPenunjang(
|
||||
penunjang,
|
||||
currentProcessingPatient.value,
|
||||
adminType
|
||||
);
|
||||
showSnackbar(result.message, "success");
|
||||
showPenunjangDialog.value = false;
|
||||
selectedPatientForPenunjang.value = null;
|
||||
};
|
||||
|
||||
const openPenunjangDialog = (patient = null) => {
|
||||
selectedPatientForPenunjang.value = patient;
|
||||
showPenunjangDialog.value = true;
|
||||
};
|
||||
|
||||
const changeKlinik = (klinik) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
const result = queueStore.changeKlinik(
|
||||
currentProcessingPatient.value,
|
||||
klinik,
|
||||
adminType
|
||||
);
|
||||
showSnackbar(result.message, result.success ? "success" : "error");
|
||||
showChangeKlinikDialog.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const processNextQueue = () => {
|
||||
const result = queueStore.processNextQueue(adminType);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === "current") {
|
||||
return "text-success font-weight-bold";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
snackbar,
|
||||
snackbarText,
|
||||
snackbarColor,
|
||||
showKlinikDialog,
|
||||
showPenunjangDialog,
|
||||
showChangeKlinikDialog,
|
||||
klinikSearch,
|
||||
penunjangSearch,
|
||||
changeKlinikSearch,
|
||||
selectedPatientForPenunjang,
|
||||
|
||||
// Computed
|
||||
currentProcessingPatient,
|
||||
diLoketPatients,
|
||||
terlambatPatients,
|
||||
pendingPatients,
|
||||
waitingPatients,
|
||||
nextPatient,
|
||||
totalPasien,
|
||||
quotaUsed,
|
||||
filteredKliniks,
|
||||
filteredPenunjangs,
|
||||
filteredChangeKliniks,
|
||||
stagePatients,
|
||||
|
||||
// Methods
|
||||
showSnackbar,
|
||||
callNext,
|
||||
callMultiplePatients,
|
||||
processPatient,
|
||||
selectKlinik,
|
||||
selectPenunjang,
|
||||
openPenunjangDialog,
|
||||
changeKlinik,
|
||||
processNextQueue,
|
||||
getRowClass,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import type { SnackbarState } from '~/types/checkin'
|
||||
|
||||
export const useSnackbar = () => {
|
||||
const snackbar = ref<SnackbarState>({
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+234
-281
@@ -1,299 +1,252 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<!-- Navigation Drawer -->
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
app
|
||||
color="white"
|
||||
width="280"
|
||||
rail-width="72"
|
||||
>
|
||||
<!-- Header Sidebar -->
|
||||
<div class="pa-4 d-flex align-center" style="height: 64px;">
|
||||
<v-icon color="#ff9248" size="32">mdi-hospital-building</v-icon>
|
||||
<v-toolbar-title v-show="!rail" class="ml-3 text-h6">
|
||||
Antrean RSSA
|
||||
</v-toolbar-title>
|
||||
</div>
|
||||
<v-app id="inspire">
|
||||
<SideBar
|
||||
:items="filteredNavItems"
|
||||
v-model:drawer="drawer"
|
||||
:rail="rail"
|
||||
@toggle-rail="rail = !rail"
|
||||
/>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<v-list density="default" nav class="py-2">
|
||||
<v-list-subheader v-show="!rail" class="text-caption text-grey">
|
||||
OVERVIEW
|
||||
</v-list-subheader>
|
||||
|
||||
<template v-for="item in items" :key="item.title">
|
||||
<!-- Item dengan Children -->
|
||||
<v-menu
|
||||
v-if="item.children"
|
||||
open-on-hover
|
||||
location="end"
|
||||
:disabled="!rail"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-group v-if="!rail" :value="item.title">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:active="item.title === currentActiveMenu"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:to="child.to"
|
||||
:title="child.title"
|
||||
:active="child.to === currentRoute.path"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1 pl-12"
|
||||
>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Rail mode menu -->
|
||||
<v-list-item
|
||||
v-else
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:active="item.title === currentActiveMenu"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- Submenu untuk rail mode -->
|
||||
<v-list class="py-2" style="min-width: 200px;">
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:to="child.to"
|
||||
:title="child.title"
|
||||
:active="child.to === currentRoute.path"
|
||||
rounded="lg"
|
||||
class="mx-2"
|
||||
>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- Item tanpa Children -->
|
||||
<v-list-item
|
||||
v-else
|
||||
:prepend-icon="item.icon"
|
||||
:title="!rail ? item.title : ''"
|
||||
:to="item.to"
|
||||
:active="item.to === currentRoute.path"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
<template v-slot:append v-if="item.badge && !rail">
|
||||
<v-chip size="x-small" color="primary">{{ item.badge }}</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<v-list-subheader v-show="!rail" class="text-caption text-grey">
|
||||
ACCOUNT
|
||||
</v-list-subheader>
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-cog-outline"
|
||||
:title="!rail ? 'Settings' : ''"
|
||||
to="/settings"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
:title="!rail ? 'Log out' : ''"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<template v-slot:append>
|
||||
<!-- Theme Toggle -->
|
||||
<div class="pa-4 d-flex justify-center align-center">
|
||||
<v-icon color="primary" size="20">mdi-white-balance-sunny</v-icon>
|
||||
<v-switch
|
||||
v-show="!rail"
|
||||
v-model="darkMode"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mx-2"
|
||||
></v-switch>
|
||||
<v-icon v-show="!rail" size="20">mdi-moon-waning-crescent</v-icon>
|
||||
</div>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="pa-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar color="#ff9248" size="40">
|
||||
<span class="text-white">AS</span>
|
||||
</v-avatar>
|
||||
<div v-show="!rail" class="ml-3 flex-grow-1">
|
||||
<div class="text-subtitle-2 font-weight-bold">Adam Sulfat</div>
|
||||
<div class="text-caption text-grey">adam@rssa.com</div>
|
||||
</div>
|
||||
<v-btn
|
||||
v-show="!rail"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
>
|
||||
<v-icon size="20">mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- Main Content -->
|
||||
<v-main>
|
||||
<!-- Top Bar untuk Toggle -->
|
||||
<v-app-bar flat color="transparent" height="64">
|
||||
<v-btn
|
||||
icon
|
||||
@click="rail = !rail"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon>
|
||||
<v-icon>mdi-bell-outline</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
|
||||
<v-container fluid class="pa-6">
|
||||
<slot></slot>
|
||||
</v-container>
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
|
||||
<!-- Footer -->
|
||||
<v-footer app class="bg-grey-lighten-4">
|
||||
<v-container fluid class="py-2">
|
||||
<v-row no-gutters align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<span class="text-caption text-grey-darken-2">
|
||||
RSUD Dr. Saiful Anwar Malang | Jl. Jaksa Agung Suprapto No. 2 Malang | Telp: 0341-362101
|
||||
</span>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="text-right">
|
||||
<span class="text-caption text-grey-darken-2">
|
||||
ITIKom Antrian RSSA Ver. 0.2
|
||||
</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-footer>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute } from 'vue-router';
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import SideBar from "../components/layout/SideBar.vue";
|
||||
// Ensure this path matches your store location
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
|
||||
// State
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'permissions']
|
||||
})
|
||||
|
||||
// State for controlling the sidebar
|
||||
const drawer = ref(true);
|
||||
const rail = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const rail = ref(true);
|
||||
|
||||
// Menu Items
|
||||
const items = ref([
|
||||
{ title: "Dashboard", icon: "mdi-view-dashboard-outline", to: "/dashboard" },
|
||||
// { title: "Marketplace", icon: "mdi-storefront-outline", to: "/marketplace" },
|
||||
// { title: "My Properties", icon: "mdi-home-outline", to: "/properties" },
|
||||
// { title: "Auctions", icon: "mdi-gavel", to: "/auctions" },
|
||||
// { title: "Wallet", icon: "mdi-wallet-outline", to: "/wallet" },
|
||||
// { title: "Favorites", icon: "mdi-heart-outline", to: "/favorites" },
|
||||
{
|
||||
title: "Setting",
|
||||
icon: "mdi-cog-outline",
|
||||
children: [
|
||||
{ title: "Hak Akses", to: "/setting/hak-akses" },
|
||||
{ title: "User Login", to: "/setting/user-login" },
|
||||
{ title: "Master Loket", to: "/setting/master-loket" },
|
||||
{ title: "Master Klinik", to: "/setting/master-klinik" },
|
||||
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
|
||||
{ title: "Screen", to: "/setting/screen" },
|
||||
],
|
||||
},
|
||||
{ title: "Loket Admin", icon: "mdi-account-supervisor-outline", to: "/loket-admin" },
|
||||
{ title: "Ranap Admin", icon: "mdi-bed-outline", to: "/ranap-admin" },
|
||||
{
|
||||
title: "Anjungan",
|
||||
icon: "mdi-account-box-multiple-outline",
|
||||
children: [
|
||||
{ title: "Anjungan", to: "/anjungan/anjungan" },
|
||||
{ title: "Klinik Ruang", to: "/anjungan/AntrianKlinik"},
|
||||
],
|
||||
},
|
||||
{ title: "Data Pasien", icon: "mdi-account-multiple-outline", to: "/data-pasien" },
|
||||
]);
|
||||
const navItemsStore = useNavItemsStore();
|
||||
const { user, checkAuth } = useAuth();
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const currentActiveMenu = computed(() => {
|
||||
const currentItem = items.value.find(item => item.to === currentRoute.path);
|
||||
if (currentItem) {
|
||||
return currentItem.title;
|
||||
}
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
if (item.children) {
|
||||
const childItem = item.children.find(child => child.to === currentRoute.path);
|
||||
if (childItem) {
|
||||
return item.title;
|
||||
interface BackendPermission {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface PermissionResponse {
|
||||
message?: string;
|
||||
data?: BackendPermission[];
|
||||
meta?: {
|
||||
count: number;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cache for API permissions
|
||||
const apiPermissions = ref<BackendPermission[]>([]);
|
||||
const currentUserRoles = ref<string[]>([]);
|
||||
const currentUserGroups = ref<string[]>([]);
|
||||
|
||||
// Get current user data with roles and groups
|
||||
const fetchCurrentUserData = async () => {
|
||||
try {
|
||||
const userData = await $fetch('/api/users/current');
|
||||
currentUserRoles.value = [
|
||||
...(userData.realmRoles || []),
|
||||
...(userData.roles || []),
|
||||
];
|
||||
|
||||
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
|
||||
const groups: string[] = [];
|
||||
(userData.groups || []).forEach((g: string) => {
|
||||
const parts = g.split('/').filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
groups.push(parts[1]); // Get second part as group name
|
||||
} else if (parts.length === 1) {
|
||||
groups.push(parts[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
currentUserGroups.value = groups;
|
||||
|
||||
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
|
||||
} catch (error) {
|
||||
console.error('Error fetching current user data:', error);
|
||||
return { roles: [], groups: [] };
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API (only for nav filtering, not for saving)
|
||||
// Saving is now handled by permissions middleware
|
||||
const fetchPermissionsFromAPI = async () => {
|
||||
const { roles, groups } = await fetchCurrentUserData();
|
||||
|
||||
if (roles.length === 0 || groups.length === 0) {
|
||||
console.warn('No roles or groups found for current user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first role and first group (or combine as needed)
|
||||
const primaryRole = roles[0] || '';
|
||||
const primaryGroup = groups[0] || '';
|
||||
|
||||
if (!primaryRole || !primaryGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<PermissionResponse>('/api/permission', {
|
||||
query: {
|
||||
roles: primaryRole,
|
||||
groups: primaryGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
apiPermissions.value = response.data;
|
||||
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions from API:', error);
|
||||
// Fallback to local storage if API fails
|
||||
apiPermissions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNavItems = computed(() => {
|
||||
// If no API permissions, check local storage as fallback
|
||||
if (apiPermissions.value.length === 0) {
|
||||
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
|
||||
const roleCandidates = [
|
||||
...(user.value?.roles || []),
|
||||
...(user.value?.realm_access?.roles || []),
|
||||
].map((role) => role.toLowerCase());
|
||||
|
||||
const localPermission = hakAksesData.value.find((item) =>
|
||||
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
|
||||
);
|
||||
|
||||
if (localPermission) {
|
||||
const permissionMap = new Map(
|
||||
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
|
||||
);
|
||||
|
||||
const applyFilter = (items: NavItem[]): NavItem[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const menuPerm = permissionMap.get(item.name.toLowerCase());
|
||||
const filteredChildren = item.children ? applyFilter(item.children) : [];
|
||||
const allowThis = menuPerm ? menuPerm.canAccess : false;
|
||||
const hasChildren = filteredChildren.length > 0;
|
||||
|
||||
if (!allowThis && !hasChildren) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(hasChildren ? { children: filteredChildren } : {}),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
return applyFilter(navItemsStore.navItems);
|
||||
}
|
||||
|
||||
// If no permissions found, show all items
|
||||
return navItemsStore.navItems;
|
||||
}
|
||||
|
||||
// Use API permissions to filter
|
||||
const permissionMap = new Map(
|
||||
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
|
||||
);
|
||||
|
||||
// Mapping untuk pagename dari API ke nama menu di sidebar
|
||||
const pagenameToMenuMapping: Record<string, string[]> = {
|
||||
'halaman utama': ['dashboard', 'halaman utama'],
|
||||
'pengaturan': ['master data'],
|
||||
'halaman': ['master data'],
|
||||
'dashboard': ['dashboard'],
|
||||
};
|
||||
|
||||
const applyFilter = (items: NavItem[]): NavItem[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
// Try to match by pagename or menu name
|
||||
let perm = permissionMap.get(item.name.toLowerCase());
|
||||
|
||||
// If no direct match, try fuzzy matching
|
||||
if (!perm) {
|
||||
perm = Array.from(permissionMap.values()).find(p => {
|
||||
const pagenameLower = p.pagename?.toLowerCase() || '';
|
||||
const menuNameLower = item.name.toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (pagenameLower === menuNameLower) return true;
|
||||
|
||||
// Contains match
|
||||
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check mapping
|
||||
const mappedMenus = pagenameToMenuMapping[pagenameLower];
|
||||
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const filteredChildren = item.children ? applyFilter(item.children) : [];
|
||||
const allowThis = perm ? (perm.active || perm.read) : false;
|
||||
const hasChildren = filteredChildren.length > 0;
|
||||
|
||||
// If permission allows and has children, show item with filtered children
|
||||
// If permission allows but no children, show item
|
||||
// If no permission but has allowed children, show item with children
|
||||
if (!allowThis && !hasChildren) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(hasChildren ? { children: filteredChildren } : {}),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
return applyFilter(navItemsStore.navItems);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await checkAuth();
|
||||
await fetchPermissionsFromAPI();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item--active {
|
||||
background-color: #e8f5e9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.v-list-item--active :deep(.v-list-item__prepend) {
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-list-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.v-navigation-drawer {
|
||||
border-right: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
/* Global styles for layout */
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Pastikan v-main mengisi seluruh tinggi halaman untuk mengaktifkan centering */
|
||||
.v-main {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
|
||||
console.log('🛡️ Auth middleware triggered for:', to.path);
|
||||
|
||||
// Check for bypass flag (from keyboard shortcut)
|
||||
if (process.client) {
|
||||
const bypassFlag = sessionStorage.getItem('bypassRootRedirect');
|
||||
if (bypassFlag === 'true' && to.path === '/') {
|
||||
console.log('🔑 Bypass flag detected - allowing root access');
|
||||
// Clear the flag immediately to prevent future bypasses
|
||||
sessionStorage.removeItem('bypassRootRedirect');
|
||||
return; // Allow access without any redirect
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect root path to LoginPage
|
||||
if (to.path === '/') {
|
||||
console.log('🔄 Redirecting from root to LoginPage');
|
||||
return navigateTo('/LoginPage', { replace: true });
|
||||
}
|
||||
|
||||
// Allow the login page to handle its own checks without redirection loops
|
||||
if (to.path === '/LoginPage') {
|
||||
console.log('⏭️ Allowing access to LoginPage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip auth check if it's the development server side render pass.
|
||||
if (process.server && process.env.NODE_ENV === 'development') {
|
||||
console.log('⏭️ Skipping intensive check on server-side during development');
|
||||
useAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for the authentication signal from a successful login redirect
|
||||
const isAuthRedirect: boolean = to.query.authenticated === 'true';
|
||||
|
||||
if (isAuthRedirect) {
|
||||
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const { checkAuth } = useAuth();
|
||||
console.log('🔍 Checking authentication status using useAuth...');
|
||||
|
||||
const user = await checkAuth();
|
||||
|
||||
if (user) {
|
||||
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email);
|
||||
return;
|
||||
} else {
|
||||
console.log('❌ No valid session found, redirecting to login');
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Auth middleware error:', error);
|
||||
console.log('🔄 Redirecting to login due to error');
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// middleware/guest.ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
console.log('👋 Guest middleware triggered for:', to.path);
|
||||
|
||||
// Skip middleware on server-side during build/generation
|
||||
if (process.server && process.env.NODE_ENV === 'development') {
|
||||
console.log('⏭️ Skipping guest check on server-side during development');
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthRedirect = to.query.authenticated === 'true';
|
||||
const isLogoutSuccess = to.query.logout === 'success';
|
||||
|
||||
// If this is a logout success, allow access to login page
|
||||
if (isLogoutSuccess) {
|
||||
console.log('✅ Logout success detected, allowing access to login page');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is a redirect from a successful login, we need to wait
|
||||
if (isAuthRedirect) {
|
||||
console.log('⏳ Client-side is processing a new login session, waiting for session to be established...');
|
||||
// We navigate to a clean URL to remove the query parameter from the user's view
|
||||
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔍 Checking if user is already authenticated...');
|
||||
|
||||
// The $fetch will automatically send the new user_session cookie
|
||||
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
|
||||
|
||||
if (session && session.user) {
|
||||
console.log('✅ User already authenticated, redirecting to dashboard');
|
||||
return navigateTo('/dashboard');
|
||||
} else {
|
||||
console.log('ℹ️ No session found, staying on login page');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Auth check failed (expected for login), staying on login page');
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// middleware/permissions.ts
|
||||
// Auto-save user permissions to localStorage when user is authenticated
|
||||
import { defineNuxtRouteMiddleware } from '#app';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
interface BackendPermission {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface PermissionResponse {
|
||||
message?: string;
|
||||
data?: BackendPermission[];
|
||||
meta?: {
|
||||
count: number;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Save permissions to allHakAksesData in localStorage
|
||||
const savePermissionsToHakAksesData = async (
|
||||
backendPermissions: BackendPermission[],
|
||||
role: string,
|
||||
group: string
|
||||
) => {
|
||||
try {
|
||||
// Get user data for additional info
|
||||
const userData = await $fetch('/api/users/current').catch(() => null);
|
||||
|
||||
// Get existing hak akses data from localStorage
|
||||
const allHakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
|
||||
|
||||
// Check if entry already exists for this role+group combination
|
||||
const existingIndex = allHakAksesData.value.findIndex(
|
||||
(item) => item.role === role && item.group === group
|
||||
);
|
||||
|
||||
// Get navItemsStore to build menu template
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Build menu template from navItems
|
||||
const buildMenuTemplate = (items: NavItem[]): any[] => {
|
||||
const result: any[] = [];
|
||||
const walk = (list: NavItem[]) => {
|
||||
list.forEach((item) => {
|
||||
result.push({
|
||||
name: item.name,
|
||||
canAccess: false,
|
||||
canView: false,
|
||||
canAdd: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
});
|
||||
if (item.children?.length) {
|
||||
walk(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(items);
|
||||
return result;
|
||||
};
|
||||
|
||||
const menuTemplate = buildMenuTemplate(navItemsStore.navItems);
|
||||
|
||||
// Map backend permissions to menu items
|
||||
const mappedPermissions = menuTemplate.map((menu) => {
|
||||
// Find matching permission from backend (by pagename or menu name)
|
||||
const backendPerm = backendPermissions.find((perm) =>
|
||||
perm.pagename?.toLowerCase() === menu.name.toLowerCase() ||
|
||||
perm.pagename?.toLowerCase().includes(menu.name.toLowerCase()) ||
|
||||
menu.name.toLowerCase().includes(perm.pagename?.toLowerCase() || '')
|
||||
);
|
||||
|
||||
if (backendPerm) {
|
||||
return {
|
||||
name: menu.name,
|
||||
canAccess: backendPerm.active || backendPerm.read || false,
|
||||
canView: backendPerm.read || false,
|
||||
canAdd: backendPerm.create || false,
|
||||
canEdit: backendPerm.update || false,
|
||||
canDelete: backendPerm.delete || false,
|
||||
};
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
|
||||
// Create hak akses data entry
|
||||
const hakAksesEntry = {
|
||||
id: existingIndex > -1 ? allHakAksesData.value[existingIndex].id :
|
||||
(allHakAksesData.value.length > 0
|
||||
? Math.max(...allHakAksesData.value.map(i => i.id || 0)) + 1
|
||||
: 1),
|
||||
userId: userData?.id || '',
|
||||
namaLengkap: userData?.namaLengkap || '',
|
||||
namaUser: userData?.namaUser || '',
|
||||
tipeUser: userData?.tipeUser || '',
|
||||
role: role,
|
||||
group: group,
|
||||
namaTipeUser: userData?.tipeUser || role,
|
||||
hakAksesMenu: mappedPermissions,
|
||||
// Store backend permissions for reference
|
||||
backendPermissions: backendPermissions,
|
||||
};
|
||||
|
||||
if (existingIndex > -1) {
|
||||
// Update existing entry
|
||||
allHakAksesData.value[existingIndex] = hakAksesEntry;
|
||||
console.log('✅ [Permissions Middleware] Updated existing hak akses data for', role, '/', group);
|
||||
} else {
|
||||
// Add new entry
|
||||
allHakAksesData.value.push(hakAksesEntry);
|
||||
console.log('✅ [Permissions Middleware] Added new hak akses data for', role, '/', group);
|
||||
}
|
||||
|
||||
console.log('💾 [Permissions Middleware] Permissions saved to allHakAksesData:', {
|
||||
role,
|
||||
group,
|
||||
permissionsCount: backendPermissions.length,
|
||||
menuCount: mappedPermissions.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [Permissions Middleware] Error saving permissions to hak akses data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API and save to localStorage
|
||||
const fetchAndSavePermissions = async () => {
|
||||
// Skip on server-side
|
||||
if (process.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already processed permissions in this session
|
||||
const sessionKey = 'permissions_synced';
|
||||
if (sessionStorage.getItem(sessionKey)) {
|
||||
console.log('⏭️ [Permissions Middleware] Permissions already synced in this session');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user data with roles and groups
|
||||
const userData = await $fetch('/api/users/current').catch(() => null);
|
||||
|
||||
if (!userData) {
|
||||
console.warn('⚠️ [Permissions Middleware] No user data found');
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = [
|
||||
...(userData.realmRoles || []),
|
||||
...(userData.roles || []),
|
||||
];
|
||||
|
||||
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
|
||||
const groups: string[] = [];
|
||||
(userData.groups || []).forEach((g: string) => {
|
||||
const parts = g.split('/').filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
groups.push(parts[1]); // Get second part as group name
|
||||
} else if (parts.length === 1) {
|
||||
groups.push(parts[0]);
|
||||
}
|
||||
});
|
||||
|
||||
if (roles.length === 0 || groups.length === 0) {
|
||||
console.warn('⚠️ [Permissions Middleware] No roles or groups found for current user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first role and first group
|
||||
const primaryRole = roles[0] || '';
|
||||
const primaryGroup = groups[0] || '';
|
||||
|
||||
if (!primaryRole || !primaryGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 [Permissions Middleware] Fetching permissions for', primaryRole, '/', primaryGroup);
|
||||
|
||||
// Fetch permissions from API
|
||||
const response = await $fetch<PermissionResponse>('/api/permission', {
|
||||
query: {
|
||||
roles: primaryRole,
|
||||
groups: primaryGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data) && response.data.length > 0) {
|
||||
// Save permissions to localStorage
|
||||
await savePermissionsToHakAksesData(response.data, primaryRole, primaryGroup);
|
||||
|
||||
// Mark as synced in this session
|
||||
sessionStorage.setItem(sessionKey, 'true');
|
||||
console.log('✅ [Permissions Middleware] Permissions synced successfully');
|
||||
} else {
|
||||
console.warn('⚠️ [Permissions Middleware] No permissions data received from API');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [Permissions Middleware] Error fetching/saving permissions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only run on client-side
|
||||
if (process.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip for login page
|
||||
if (to.path === '/LoginPage') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run async permission sync (non-blocking)
|
||||
// This will only run once per session due to sessionStorage check
|
||||
fetchAndSavePermissions().catch(err => {
|
||||
console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
|
||||
});
|
||||
});
|
||||
+111
-29
@@ -1,37 +1,119 @@
|
||||
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-05-15',
|
||||
devtools: { enabled: true },
|
||||
compatibilityDate: "2025-05-15",
|
||||
devtools: {
|
||||
enabled: true,
|
||||
timeline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
|
||||
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',
|
||||
'@nuxt/fonts',
|
||||
'@nuxt/icon',
|
||||
'@nuxt/image',
|
||||
'@nuxt/scripts',
|
||||
'@nuxt/test-utils',
|
||||
'@nuxt/ui',
|
||||
(_options, nuxt) => {
|
||||
nuxt.hooks.hook('vite:extendConfig', (config) => {
|
||||
// "@nuxt/content",
|
||||
"@nuxt/eslint",
|
||||
"@nuxt/fonts",
|
||||
"@nuxt/icon",
|
||||
"@nuxt/image",
|
||||
"@nuxt/scripts",
|
||||
"@nuxt/test-utils",
|
||||
"@nuxt/ui",
|
||||
"@pinia/nuxt",
|
||||
"@vesp/nuxt-fontawesome",
|
||||
"@nuxtjs/google-fonts",
|
||||
async (_options, nuxt) => {
|
||||
nuxt.hooks.hook("vite:extendConfig", async (config) => {
|
||||
// @ts-expect-error
|
||||
config.plugins.push(vuetify({ autoImport: true }))
|
||||
})
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
],
|
||||
build: {
|
||||
transpile: ['vuetify'] // Important for Nuxt 3 with Vuetify
|
||||
|
||||
fontawesome: {
|
||||
icons: {
|
||||
solid: ["dna", "user", "home", "gear"],
|
||||
regular: ["heart"],
|
||||
brands: ["github"],
|
||||
},
|
||||
},
|
||||
css: [
|
||||
'vuetify/lib/styles/main.sass', // Or 'vuetify/styles' depending on version
|
||||
'@mdi/font/css/materialdesignicons.min.css',
|
||||
],
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ['vuetify']
|
||||
googleFonts: {
|
||||
families: {
|
||||
Inter: [400, 500, 600, 700],
|
||||
},
|
||||
plugins: [
|
||||
vuetify({ autoImport: true }) // If using vite-plugin-vuetify
|
||||
]
|
||||
}
|
||||
})
|
||||
display: "swap",
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
authSecret: process.env.NUXT_AUTH_SECRET,
|
||||
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
|
||||
public: {
|
||||
authUrl: process.env.AUTH_ORIGIN,
|
||||
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
|
||||
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
transpile: ["vuetify"],
|
||||
},
|
||||
|
||||
css: [
|
||||
"vuetify/lib/styles/main.sass",
|
||||
"@mdi/font/css/materialdesignicons.min.css",
|
||||
"~/assets/scss/main.scss",
|
||||
],
|
||||
|
||||
devServer: {
|
||||
port: 3001,
|
||||
host: '10.10.150.175'
|
||||
},
|
||||
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
@use "sass:math";
|
||||
@use "sass:map";
|
||||
@use "~/assets/scss/_variables.scss" as *;
|
||||
@use "~/assets/scss/_colors.scss" as *;
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ["vuetify"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+4855
-5594
File diff suppressed because it is too large
Load diff
+24
-2
@@ -4,28 +4,50 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"_command_dev": "nuxt dev -o --host --port 3001",
|
||||
"_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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@nuxt/content": "^3.6.3",
|
||||
"@nuxt/content": "^2.7.2",
|
||||
"@nuxt/eslint": "^1.7.1",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/scripts": "^0.11.10",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@nuxt/ui": "^3.3.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@unhead/vue": "^2.0.13",
|
||||
"@vesp/nuxt-fontawesome": "^2.0.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"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",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"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",
|
||||
"vuetify": "^3.9.3"
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<div class="loket-container">
|
||||
<!-- Compact Header -->
|
||||
<PageHeader
|
||||
icon="mdi-hospital-building"
|
||||
title="Admin Klinik"
|
||||
:subtitle="currentDate"
|
||||
:show-add-button="false"
|
||||
theme="secondary"
|
||||
/>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<v-row class="content-grid" dense>
|
||||
<!-- Left Column: Current Patient & Queue Actions -->
|
||||
<v-col cols="12" md="5">
|
||||
<div class="sticky-wrapper">
|
||||
<!-- Current Patient Card -->
|
||||
<CurrentPatientCard
|
||||
:patient="currentProcessingPatient"
|
||||
theme="secondary"
|
||||
:has-next-queue="(diLoketPatients || []).length > 0"
|
||||
:next-queue-info="nextQueueInfo"
|
||||
@action="handlePatientAction"
|
||||
@change-klinik="showChangeKlinikDialog = true"
|
||||
@process-next="handleProcessNext"
|
||||
/>
|
||||
|
||||
<!-- Queue Actions Card -->
|
||||
<QueueActionsCard
|
||||
class="mt-3"
|
||||
:total-quota="150"
|
||||
:used-quota="quotaUsed"
|
||||
:has-next="!!nextPatient"
|
||||
@call="handleCall"
|
||||
/>
|
||||
|
||||
<!-- Create Queue Buttons -->
|
||||
<div class="create-buttons mt-3">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="6" class="pr-2">
|
||||
<v-btn
|
||||
block
|
||||
class="py-6"
|
||||
color="primary-600"
|
||||
:disabled="!currentProcessingPatient"
|
||||
@click="showKlinikDialog = true"
|
||||
>
|
||||
<v-icon start>mdi-hospital-building</v-icon>
|
||||
Buat Antrean Klinik
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" class="pl-2">
|
||||
<v-btn
|
||||
block
|
||||
class="py-6 text-white"
|
||||
color="secondary-600"
|
||||
:disabled="!currentProcessingPatient"
|
||||
@click="openPenunjangDialog()"
|
||||
>
|
||||
<v-icon start>mdi-clipboard-pulse</v-icon>
|
||||
Buat Antrean Penunjang
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Column: Patient Table -->
|
||||
<v-col cols="12" md="7">
|
||||
<v-card class="patient-data-container" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Header with Filters -->
|
||||
<div class="data-header mb-4">
|
||||
<div class="section-label">DATA PASIEN</div>
|
||||
|
||||
<div class="filters">
|
||||
<!-- Status Filter -->
|
||||
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
|
||||
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
|
||||
Semua ({{ allPatients.length }})
|
||||
</v-chip>
|
||||
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
|
||||
Di Klinik ({{ (diLoketPatients || []).length }})
|
||||
</v-chip>
|
||||
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
|
||||
Terlambat ({{ (terlambatPatients || []).length }})
|
||||
</v-chip>
|
||||
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
|
||||
Pending ({{ (pendingPatients || []).length }})
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
|
||||
<!-- Fast Track Filter -->
|
||||
<v-select
|
||||
v-model="selectedFastTrack"
|
||||
:items="fastTrackOptions"
|
||||
label="Filter Fast Track"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="fast-track-filter"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="20">mdi-flash</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<!-- Search Field -->
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Cari barcode, nomor antrian..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="search-field"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PatientDataTable
|
||||
:items="allPatients"
|
||||
v-model:selected-status="selectedStatus"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:selected-fast-track="selectedFastTrack"
|
||||
:di-loket-count="(diLoketPatients || []).length"
|
||||
:terlambat-count="(terlambatPatients || []).length"
|
||||
:pending-count="(pendingPatients || []).length"
|
||||
:status-labels="statusLabels"
|
||||
:show-diproses="false"
|
||||
@action="handleTableAction"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Klinik Dialog -->
|
||||
<SelectionDialog
|
||||
v-model="showKlinikDialog"
|
||||
title="Pilih Klinik"
|
||||
:items="filteredKliniks"
|
||||
v-model:search-query="klinikSearch"
|
||||
search-placeholder="Cari klinik..."
|
||||
@select="selectKlinik"
|
||||
/>
|
||||
|
||||
<!-- Penunjang Dialog -->
|
||||
<SelectionDialog
|
||||
v-model="showPenunjangDialog"
|
||||
title="Pilih Penunjang"
|
||||
:items="filteredPenunjangs"
|
||||
v-model:search-query="penunjangSearch"
|
||||
search-placeholder="Cari penunjang..."
|
||||
@select="selectPenunjang"
|
||||
/>
|
||||
|
||||
<!-- Change Klinik Dialog -->
|
||||
<SelectionDialog
|
||||
v-model="showChangeKlinikDialog"
|
||||
title="Ubah Klinik"
|
||||
:items="filteredChangeKliniks"
|
||||
v-model:search-query="changeKlinikSearch"
|
||||
search-placeholder="Cari klinik..."
|
||||
@select="changeKlinik"
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<AppSnackbar
|
||||
v-model="snackbar"
|
||||
:message="snackbarText"
|
||||
:color="snackbarColor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useQueue } from "@/composables/useQueue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
|
||||
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
|
||||
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
|
||||
import SelectionDialog from "@/components/common/SelectionDialog.vue";
|
||||
import AppSnackbar from "@/components/common/AppSnackbar.vue";
|
||||
|
||||
const {
|
||||
snackbar,
|
||||
snackbarText,
|
||||
snackbarColor,
|
||||
showKlinikDialog,
|
||||
showPenunjangDialog,
|
||||
showChangeKlinikDialog,
|
||||
klinikSearch,
|
||||
penunjangSearch,
|
||||
changeKlinikSearch,
|
||||
currentProcessingPatient,
|
||||
diLoketPatients,
|
||||
terlambatPatients,
|
||||
pendingPatients,
|
||||
nextPatient,
|
||||
quotaUsed,
|
||||
filteredKliniks,
|
||||
filteredPenunjangs,
|
||||
filteredChangeKliniks,
|
||||
callNext,
|
||||
callMultiplePatients,
|
||||
processPatient,
|
||||
processNextQueue,
|
||||
selectKlinik,
|
||||
selectPenunjang,
|
||||
openPenunjangDialog,
|
||||
changeKlinik,
|
||||
} = useQueue("klinik");
|
||||
|
||||
const currentDate = ref(
|
||||
new Date().toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
);
|
||||
|
||||
const selectedStatus = ref("all");
|
||||
const searchQuery = ref("");
|
||||
const selectedFastTrack = ref(null);
|
||||
|
||||
// Fast Track options from all patients
|
||||
const fastTrackOptions = computed(() => {
|
||||
const normalizedValues = allPatients.value
|
||||
.map((p) => (p.fastTrack ?? "").toString().trim().toUpperCase())
|
||||
.filter((v) => v.length > 0);
|
||||
const uniqueTracks = [...new Set(normalizedValues)];
|
||||
return uniqueTracks.sort();
|
||||
});
|
||||
|
||||
// Custom status labels for Klinik
|
||||
const statusLabels = {
|
||||
all: 'Semua',
|
||||
diloket: 'Di Klinik',
|
||||
terlambat: 'Terlambat',
|
||||
pending: 'Pending'
|
||||
};
|
||||
|
||||
// Combine all patients dengan status
|
||||
const allPatients = computed(() => {
|
||||
try {
|
||||
const diLoketList = diLoketPatients.value || [];
|
||||
const terlambatList = terlambatPatients.value || [];
|
||||
const pendingList = pendingPatients.value || [];
|
||||
|
||||
const diLoket = diLoketList.map(p => ({ ...p, status: 'diloket' }));
|
||||
const terlambat = terlambatList.map(p => ({ ...p, status: 'terlambat' }));
|
||||
const pending = pendingList.map(p => ({ ...p, status: 'pending' }));
|
||||
|
||||
return [...diLoket, ...terlambat, ...pending];
|
||||
} catch (error) {
|
||||
console.error('Error in allPatients computed:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Next queue info untuk CurrentPatientCard (Admin Klinik)
|
||||
const nextQueueInfo = computed(() => {
|
||||
const currentPatientNo = currentProcessingPatient.value?.no;
|
||||
const nextPatient =
|
||||
(diLoketPatients.value || []).find((p) => p.no !== currentPatientNo) ||
|
||||
(diLoketPatients.value || [])[0];
|
||||
|
||||
if (nextPatient) {
|
||||
return `Antrian berikutnya: ${nextPatient.noAntrian.split(" |")[0]}`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const handlePatientAction = (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
processPatient(currentProcessingPatient.value, action);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCall = (count) => {
|
||||
if (count === 1) {
|
||||
callNext();
|
||||
} else {
|
||||
callMultiplePatients(count);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableAction = (item, action) => {
|
||||
processPatient(item, action);
|
||||
};
|
||||
|
||||
const handleProcessNext = () => {
|
||||
processNextQueue();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loket-container {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
.content-grid > .v-col {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.create-buttons .create-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.status-filter .v-chip {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-600);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-filter .v-chip:hover {
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.status-filter .v-chip.active-chip {
|
||||
background: var(--color-secondary-600);
|
||||
color: var(--color-neutral-100);
|
||||
border-color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.fast-track-filter {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-neutral-600);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.patient-data-container {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.loket-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.fast-track-filter {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="loket-container">
|
||||
<!-- Compact Header -->
|
||||
<PageHeader
|
||||
icon="mdi-view-dashboard"
|
||||
title="Admin Loket"
|
||||
:subtitle="currentDate"
|
||||
:show-add-button="false"
|
||||
theme="primary"
|
||||
/>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<v-row class="content-grid" dense>
|
||||
<!-- Left Column: Current Patient & Queue Actions -->
|
||||
<v-col cols="12" md="5">
|
||||
<div class="sticky-wrapper">
|
||||
<!-- Current Patient Card -->
|
||||
<CurrentPatientCard
|
||||
:patient="currentProcessingPatient"
|
||||
:has-next-queue="(diLoketPatients || []).length > 0"
|
||||
:next-queue-info="nextQueueInfo"
|
||||
@action="handlePatientAction"
|
||||
@change-klinik="showChangeKlinikDialog = true"
|
||||
@process-next="handleProcessNext"
|
||||
/>
|
||||
|
||||
<!-- Queue Actions Card -->
|
||||
<QueueActionsCard
|
||||
class="mt-3"
|
||||
:total-quota="150"
|
||||
:used-quota="quotaUsed"
|
||||
:has-next="!!nextPatient"
|
||||
@call="handleCall"
|
||||
/>
|
||||
|
||||
<!-- Create Queue Buttons -->
|
||||
<div class="create-buttons mt-3">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="6" class="pr-2">
|
||||
<v-btn
|
||||
block
|
||||
class="py-6"
|
||||
color="primary-600"
|
||||
:disabled="!currentProcessingPatient"
|
||||
@click="showKlinikDialog = true"
|
||||
>
|
||||
<v-icon start>mdi-hospital-building</v-icon>
|
||||
Buat Antrean Klinik
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" class="pl-2">
|
||||
<v-btn
|
||||
block
|
||||
class="py-6 text-white"
|
||||
color="secondary-600"
|
||||
:disabled="!currentProcessingPatient"
|
||||
@click="openPenunjangDialog()"
|
||||
>
|
||||
<v-icon start>mdi-clipboard-pulse</v-icon>
|
||||
Buat Antrean Penunjang
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Column: Patient Table -->
|
||||
<v-col cols="12" md="7">
|
||||
<v-card class="patient-data-container" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Header with Filters -->
|
||||
<div class="data-header mb-4">
|
||||
<div class="section-label">DATA PASIEN</div>
|
||||
|
||||
<div class="filters">
|
||||
<!-- Search Field -->
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Cari barcode, nomor antrian..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="search-field"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PatientDataTable
|
||||
:items="allPatientsForStage"
|
||||
v-model:selected-status="selectedStatus"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:selected-fast-track="selectedFastTrack"
|
||||
:di-loket-count="diLoketCount"
|
||||
:diproses-count="currentProcessingPatient ? 1 : 0"
|
||||
:terlambat-count="(terlambatPatients || []).length"
|
||||
:pending-count="(pendingPatients || []).length"
|
||||
:show-diproses="false"
|
||||
:fast-track-options="fastTrackOptions"
|
||||
@action="handleTableAction"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Klinik Dialog -->
|
||||
<SelectionDialog
|
||||
v-model="showKlinikDialog"
|
||||
title="Pilih Klinik"
|
||||
:items="filteredKliniks"
|
||||
v-model:search-query="klinikSearch"
|
||||
search-placeholder="Cari klinik..."
|
||||
@select="selectKlinik"
|
||||
/>
|
||||
|
||||
<!-- Penunjang Dialog -->
|
||||
<SelectionDialog
|
||||
v-model="showPenunjangDialog"
|
||||
title="Pilih Penunjang"
|
||||
:items="filteredPenunjangs"
|
||||
v-model:search-query="penunjangSearch"
|
||||
search-placeholder="Cari penunjang..."
|
||||
@select="selectPenunjang"
|
||||
/>
|
||||
|
||||
<!-- Change Klinik Dialog -->
|
||||
<SelectionDialog
|
||||
v-model="showChangeKlinikDialog"
|
||||
title="Ubah Klinik"
|
||||
:items="filteredChangeKliniks"
|
||||
v-model:search-query="changeKlinikSearch"
|
||||
search-placeholder="Cari klinik..."
|
||||
@select="changeKlinik"
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<AppSnackbar
|
||||
v-model="snackbar"
|
||||
:message="snackbarText"
|
||||
:color="snackbarColor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useQueue } from "@/composables/useQueue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
|
||||
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
|
||||
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
|
||||
import SelectionDialog from "@/components/common/SelectionDialog.vue";
|
||||
import AppSnackbar from "@/components/common/AppSnackbar.vue";
|
||||
|
||||
const {
|
||||
snackbar,
|
||||
snackbarText,
|
||||
snackbarColor,
|
||||
showKlinikDialog,
|
||||
showPenunjangDialog,
|
||||
showChangeKlinikDialog,
|
||||
klinikSearch,
|
||||
penunjangSearch,
|
||||
changeKlinikSearch,
|
||||
currentProcessingPatient,
|
||||
diLoketPatients,
|
||||
terlambatPatients,
|
||||
pendingPatients,
|
||||
nextPatient,
|
||||
quotaUsed,
|
||||
filteredKliniks,
|
||||
filteredPenunjangs,
|
||||
filteredChangeKliniks,
|
||||
callNext,
|
||||
callMultiplePatients,
|
||||
processPatient,
|
||||
selectKlinik,
|
||||
selectPenunjang,
|
||||
openPenunjangDialog,
|
||||
changeKlinik,
|
||||
processNextQueue,
|
||||
} = useQueue("loket");
|
||||
|
||||
const currentDate = ref(
|
||||
new Date().toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
);
|
||||
|
||||
const selectedStatus = ref("all");
|
||||
const searchQuery = ref("");
|
||||
const selectedFastTrack = ref(null);
|
||||
|
||||
// Fast Track options from all patients
|
||||
const fastTrackOptions = computed(() => {
|
||||
const normalizedValues = allPatientsForStage.value
|
||||
.map((p) => (p.fastTrack ?? "").toString().trim().toUpperCase())
|
||||
.filter((v) => v.length > 0);
|
||||
const uniqueTracks = [...new Set(normalizedValues)];
|
||||
return uniqueTracks.sort();
|
||||
});
|
||||
|
||||
// Combine all patients with status - PRESERVE ALL PROPERTIES
|
||||
const allPatientsForStage = computed(() => {
|
||||
const currentPatientNo = currentProcessingPatient.value?.no;
|
||||
|
||||
const diLoket = (diLoketPatients.value || []).map((p) => ({
|
||||
...p, // Spread all properties first
|
||||
// If this patient is currently being processed, mark as "diproses", otherwise "diloket"
|
||||
status: p.no === currentPatientNo ? "diproses" : "diloket",
|
||||
}));
|
||||
const terlambat = (terlambatPatients.value || []).map((p) => ({
|
||||
...p,
|
||||
status: "terlambat",
|
||||
}));
|
||||
const pending = (pendingPatients.value || []).map((p) => ({
|
||||
...p,
|
||||
status: "pending",
|
||||
}));
|
||||
|
||||
// Do not show waiting patients in the table until called
|
||||
const combined = [...diLoket, ...terlambat, ...pending];
|
||||
|
||||
// Debug: check if fastTrack property exists
|
||||
console.log("📊 AdminLoket - allPatientsForStage:", combined.length);
|
||||
if (combined.length > 0) {
|
||||
console.log("📊 First patient:", combined[0]);
|
||||
console.log("📊 First patient has fastTrack?", "fastTrack" in combined[0]);
|
||||
console.log("📊 First patient fastTrack value:", combined[0].fastTrack);
|
||||
console.log(
|
||||
"📊 All fastTrack values:",
|
||||
combined.map((p) => p.fastTrack)
|
||||
);
|
||||
}
|
||||
|
||||
return combined;
|
||||
});
|
||||
|
||||
// Count diLoket patients (excluding currently processing)
|
||||
const diLoketCount = computed(() => {
|
||||
const currentPatientNo = currentProcessingPatient.value?.no;
|
||||
return (diLoketPatients.value || []).filter(p => p.no !== currentPatientNo).length;
|
||||
});
|
||||
|
||||
// Next queue info for CurrentPatientCard
|
||||
const nextQueueInfo = computed(() => {
|
||||
const currentPatientNo = currentProcessingPatient.value?.no;
|
||||
const nextPatient = (diLoketPatients.value || []).find(p => p.no !== currentPatientNo) || (diLoketPatients.value || [])[0];
|
||||
if (nextPatient) {
|
||||
return `Antrian berikutnya: ${nextPatient.noAntrian.split(" |")[0]}`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const handlePatientAction = (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
processPatient(currentProcessingPatient.value, action);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCall = (count) => {
|
||||
if (count === 1) {
|
||||
callNext();
|
||||
} else {
|
||||
callMultiplePatients(count);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableAction = (item, action) => {
|
||||
processPatient(item, action);
|
||||
};
|
||||
|
||||
const handleProcessNext = () => {
|
||||
processNextQueue();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loket-container {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
.content-grid > .v-col {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.create-buttons .v-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.status-filter .v-chip {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-600);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-filter .v-chip:hover {
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.status-filter .v-chip.active-chip {
|
||||
background: var(--color-primary-600);
|
||||
color: var(--color-neutral-100);
|
||||
border-color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-neutral-600);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.patient-data-container {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.loket-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,699 @@
|
||||
<!-- pages/AdminPenunjang.vue -->
|
||||
<template>
|
||||
<div class="loket-container">
|
||||
<!-- Compact Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<v-icon size="28" color="white">mdi-clipboard-pulse</v-icon>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Admin Penunjang</h1>
|
||||
<p class="page-subtitle">{{ currentDate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<v-row class="content-grid" dense>
|
||||
<!-- Left Column: Current Patient -->
|
||||
<v-col cols="12" md="5">
|
||||
<div class="sticky-wrapper">
|
||||
<v-card class="current-patient-card" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="section-label mb-3">SEDANG DIPROSES</div>
|
||||
|
||||
<div v-if="currentProcessingPatient" class="patient-details">
|
||||
<div class="patient-number mb-2">{{ currentProcessingPatient.noAntrian.split(" |")[0] }}</div>
|
||||
<div class="patient-info-text mb-3">
|
||||
<div>{{ currentProcessingPatient.barcode }}</div>
|
||||
<div>{{ currentProcessingPatient.klinik }} | {{ currentProcessingPatient.pembayaran }}</div>
|
||||
</div>
|
||||
|
||||
<div class="action-grid">
|
||||
<v-btn color="#10b981" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'check-in')">
|
||||
<v-icon start size="20">mdi-check</v-icon>
|
||||
Selesai
|
||||
</v-btn>
|
||||
<v-btn color="#f59e0b" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'terlambat')">
|
||||
<v-icon start size="20">mdi-clock-alert</v-icon>
|
||||
Terlambat
|
||||
</v-btn>
|
||||
<v-btn color="#ef4444" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'pending')">
|
||||
<v-icon start size="20">mdi-pause</v-icon>
|
||||
Pending
|
||||
</v-btn>
|
||||
<v-btn color="#3b82f6" variant="flat" block size="large" @click="showChangeKlinikDialog = true">
|
||||
<v-icon start size="20">mdi-swap-horizontal</v-icon>
|
||||
Ubah Ruang
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<v-icon size="48" color="grey-lighten-2">mdi-account-off-outline</v-icon>
|
||||
<div class="empty-text">Tidak ada pasien yang diproses</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Queue Actions -->
|
||||
<v-card class="queue-actions-card mt-3" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="section-label mb-3">PANGGIL ANTREAN</div>
|
||||
|
||||
<div class="quota-info mb-3">
|
||||
<div class="quota-item">
|
||||
<span class="quota-label">Kuota</span>
|
||||
<span class="quota-value">150</span>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<span class="quota-label">Tersedia</span>
|
||||
<span class="quota-value quota-available">{{ 150 - quotaUsed }}</span>
|
||||
</div>
|
||||
<div class="quota-item full-width">
|
||||
<v-progress-linear :model-value="(quotaUsed / 150) * 100" color="#10b981" height="6" rounded class="mt-1"></v-progress-linear>
|
||||
<span class="quota-used">Terpakai: {{ quotaUsed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="call-buttons">
|
||||
<v-btn color="#10b981" variant="outlined" @click="callNext" :disabled="!nextPatient">1</v-btn>
|
||||
<v-btn color="#3b82f6" variant="outlined" @click="callMultiplePatients(5)">5</v-btn>
|
||||
<v-btn color="#f59e0b" variant="outlined" @click="callMultiplePatients(10)">10</v-btn>
|
||||
<v-btn color="#ef4444" variant="outlined" @click="callMultiplePatients(20)">20</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Create Queue Button -->
|
||||
<div class="create-buttons mt-3">
|
||||
<v-btn color="#3b82f6" variant="flat" block size="large" @click="showPenunjangDialog = true">
|
||||
<v-icon start size="20">mdi-clipboard-pulse</v-icon>
|
||||
Buat Antrean Penunjang
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Column: Patient Table -->
|
||||
<v-col cols="12" md="7">
|
||||
<v-card class="patient-table-card" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Table Header with Filters -->
|
||||
<div class="table-header mb-4">
|
||||
<div class="section-label">DATA PASIEN</div>
|
||||
|
||||
<div class="filters">
|
||||
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
|
||||
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
|
||||
Semua ({{ allPatients.length }})
|
||||
</v-chip>
|
||||
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
|
||||
Di Penunjang ({{ (diLoketPatients || []).length }})
|
||||
</v-chip>
|
||||
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
|
||||
Terlambat ({{ (terlambatPatients || []).length }})
|
||||
</v-chip>
|
||||
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
|
||||
Pending ({{ (pendingPatients || []).length }})
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Cari barcode, nomor antrian..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="search-field"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<v-data-table
|
||||
:headers="tableHeaders"
|
||||
:items="filteredPatients"
|
||||
:search="searchQuery"
|
||||
class="patient-table"
|
||||
:items-per-page="10"
|
||||
density="comfortable"
|
||||
>
|
||||
<template #item.noAntrian="{ item }">
|
||||
<div class="queue-number">{{ item.noAntrian.split(" |")[0] }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
class="status-chip"
|
||||
>
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.klinik="{ item }">
|
||||
<v-chip size="small" variant="outlined" class="klinik-chip">
|
||||
{{ item.klinik }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.aksi="{ item }">
|
||||
<div class="action-buttons-table">
|
||||
<v-btn
|
||||
v-if="item.status === 'diloket'"
|
||||
size="small"
|
||||
color="#10b981"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'proses')"
|
||||
>
|
||||
Proses
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else-if="item.status === 'terlambat'"
|
||||
size="small"
|
||||
color="#10b981"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'aktifkan')"
|
||||
>
|
||||
Aktifkan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else-if="item.status === 'pending'"
|
||||
size="small"
|
||||
color="#10b981"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'proses')"
|
||||
>
|
||||
Proses
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Penunjang Dialog -->
|
||||
<v-dialog v-model="showPenunjangDialog" max-width="800px">
|
||||
<v-card>
|
||||
<v-card-title class="dialog-header">
|
||||
<v-btn icon size="small" @click="showPenunjangDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<span>Pilih Ruang Penunjang</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-text-field
|
||||
v-model="penunjangSearch"
|
||||
placeholder="Cari penunjang..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
></v-text-field>
|
||||
|
||||
<v-row dense>
|
||||
<v-col v-for="penunjang in filteredPenunjangs" :key="penunjang.id" cols="6" sm="4">
|
||||
<v-card class="penunjang-card" @click="selectPenunjang(penunjang)" elevation="0">
|
||||
<v-card-text class="text-center pa-3">
|
||||
<div class="penunjang-name">{{ penunjang.name }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Change Ruang Dialog -->
|
||||
<v-dialog v-model="showChangeKlinikDialog" max-width="800px">
|
||||
<v-card>
|
||||
<v-card-title class="dialog-header">
|
||||
<v-btn icon size="small" @click="showChangeKlinikDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<span>Ubah Ruang Penunjang</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-text-field
|
||||
v-model="changeKlinikSearch"
|
||||
placeholder="Cari ruang..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
></v-text-field>
|
||||
|
||||
<v-row dense>
|
||||
<v-col v-for="klinik in filteredChangeKliniks" :key="klinik.id" cols="6" sm="4">
|
||||
<v-card class="klinik-card" @click="changeKlinik(klinik)" elevation="0">
|
||||
<v-card-text class="text-center pa-3">
|
||||
<div class="klinik-name">{{ klinik.name }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
|
||||
{{ snackbarText }}
|
||||
<template v-slot:actions>
|
||||
<v-btn icon size="small" @click="snackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useQueue } from "../composables/useQueue";
|
||||
|
||||
const {
|
||||
snackbar,
|
||||
snackbarText,
|
||||
snackbarColor,
|
||||
showPenunjangDialog,
|
||||
showChangeKlinikDialog,
|
||||
penunjangSearch,
|
||||
changeKlinikSearch,
|
||||
currentProcessingPatient,
|
||||
diLoketPatients,
|
||||
terlambatPatients,
|
||||
pendingPatients,
|
||||
nextPatient,
|
||||
quotaUsed,
|
||||
filteredPenunjangs,
|
||||
filteredChangeKliniks,
|
||||
callNext,
|
||||
callMultiplePatients,
|
||||
processPatient,
|
||||
selectPenunjang,
|
||||
changeKlinik,
|
||||
} = useQueue("penunjang");
|
||||
|
||||
const currentDate = ref(
|
||||
new Date().toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
);
|
||||
|
||||
const selectedStatus = ref("all");
|
||||
const searchQuery = ref("");
|
||||
|
||||
// Combine all patients with status - Fixed to prevent infinite loop
|
||||
const allPatients = computed(() => {
|
||||
try {
|
||||
// Safely access reactive values
|
||||
const diLoketList = diLoketPatients.value || [];
|
||||
const terlambatList = terlambatPatients.value || [];
|
||||
const pendingList = pendingPatients.value || [];
|
||||
|
||||
// Map with status
|
||||
const diLoket = diLoketList.map(p => ({ ...p, status: 'diloket' }));
|
||||
const terlambat = terlambatList.map(p => ({ ...p, status: 'terlambat' }));
|
||||
const pending = pendingList.map(p => ({ ...p, status: 'pending' }));
|
||||
|
||||
return [...diLoket, ...terlambat, ...pending];
|
||||
} catch (error) {
|
||||
console.error('Error in allPatients computed:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Filter patients based on selected status
|
||||
const filteredPatients = computed(() => {
|
||||
try {
|
||||
const all = allPatients.value;
|
||||
if (selectedStatus.value === "all") return all;
|
||||
return all.filter(p => p.status === selectedStatus.value);
|
||||
} catch (error) {
|
||||
console.error('Error in filteredPatients computed:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const tableHeaders = ref([
|
||||
{ title: "No", value: "no", width: "60px", sortable: false },
|
||||
{ title: "Jam Panggil", value: "jamPanggil", width: "100px" },
|
||||
{ title: "Barcode", value: "barcode", width: "130px" },
|
||||
{ title: "No Antrian", value: "noAntrian", width: "140px" },
|
||||
{ title: "Klinik", value: "klinik", width: "100px" },
|
||||
{ title: "Fast Track", value: "fastTrack", width: "100px" },
|
||||
{ title: "Pembayaran", value: "pembayaran", width: "100px" },
|
||||
{ title: "Status", value: "status", width: "100px" },
|
||||
{ title: "Aksi", value: "aksi", width: "100px", sortable: false },
|
||||
]);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
diloket: "#3b82f6",
|
||||
terlambat: "#f59e0b",
|
||||
pending: "#ef4444"
|
||||
};
|
||||
return colors[status] || "#6b7280";
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
diloket: "Di Penunjang",
|
||||
terlambat: "Terlambat",
|
||||
pending: "Pending"
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loket-container {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
.content-grid > .v-col {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.current-patient-card,
|
||||
.queue-actions-card,
|
||||
.patient-table-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.patient-details {
|
||||
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.patient-number {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.patient-info-text {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quota-item {
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quota-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quota-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.quota-available {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.quota-used {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.call-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.call-buttons .v-btn {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.create-buttons .v-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-filter .v-chip {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-filter .v-chip.active-chip {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
:deep(.patient-table) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.patient-table .v-data-table__thead) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
:deep(.patient-table th) {
|
||||
font-size: 11px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #64748b !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
|
||||
:deep(.patient-table td) {
|
||||
font-size: 13px !important;
|
||||
padding: 10px 8px !important;
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
}
|
||||
|
||||
:deep(.patient-table tbody tr:hover) {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
.queue-number {
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.klinik-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-buttons-table .v-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: #f8fafc;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.klinik-card,
|
||||
.penunjang-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.klinik-card:hover,
|
||||
.penunjang-card:hover {
|
||||
border-color: #8b5cf6;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
.klinik-name,
|
||||
.penunjang-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.loket-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.call-buttons {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
@@ -1,686 +0,0 @@
|
||||
<!-- pages/Anjungan.vue -->
|
||||
<template>
|
||||
<div class="anjungan-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-hospital-building</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Anjungan RSSA</h1>
|
||||
<p class="page-subtitle">Pilih Klinik untuk Pendaftaran</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-chip color="white" variant="flat" class="instruction-chip">
|
||||
<v-icon start size="16">mdi-information</v-icon>
|
||||
Hijau: Tersedia | Merah: Tutup/Penuh
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Section -->
|
||||
<!-- <v-card class="controls-card mb-4" elevation="2">
|
||||
<v-card-text class="py-3">
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center flex-wrap gap-3">
|
||||
<v-select
|
||||
v-model="selectedStatus"
|
||||
:items="statusOptions"
|
||||
label="Filter Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 160px;"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="Cari Klinik"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex justify-end align-center flex-wrap gap-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="refreshData"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card> -->
|
||||
|
||||
<!-- Clinic Cards -->
|
||||
<v-card elevation="2" class="main-content-card">
|
||||
<!-- <v-card-title class="d-flex align-center pa-4 bg-grey-lighten-4">
|
||||
<v-icon class="mr-2">mdi-hospital-marker</v-icon>
|
||||
<span>Daftar Klinik - {{ filteredClinics.length }} dari {{ totalClinics }} klinik</span>
|
||||
</v-card-title> -->
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="clinic in filteredClinics"
|
||||
:key="clinic.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
class="pa-2"
|
||||
>
|
||||
<v-card
|
||||
:class="{
|
||||
'clinic-card': true,
|
||||
'clinic-available': clinic.available,
|
||||
'clinic-closed': !clinic.available
|
||||
}"
|
||||
:disabled="!clinic.available"
|
||||
@click="selectClinic(clinic)"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text class="text-center pa-4">
|
||||
<!-- Status Chip -->
|
||||
<div class="mb-3">
|
||||
<v-chip
|
||||
:color="clinic.available ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ clinic.available ? 'TERSEDIA' : 'TUTUP' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="clinic-icon-wrapper mb-3">
|
||||
<v-icon
|
||||
:icon="clinic.icon"
|
||||
size="40"
|
||||
:color="clinic.available ? 'success' : 'error'"
|
||||
></v-icon>
|
||||
</div>
|
||||
|
||||
<!-- Clinic Name -->
|
||||
<h3 class="text-h6 font-weight-bold mb-2">
|
||||
{{ clinic.name }}
|
||||
</h3>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p v-if="clinic.subtitle" class="text-caption text-grey-darken-1 mb-2">
|
||||
{{ clinic.subtitle }}
|
||||
</p>
|
||||
|
||||
<!-- Shift Info -->
|
||||
<div class="shift-info">
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="clinic.available ? 'info' : 'error'"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
>
|
||||
{{ clinic.shift }}
|
||||
</v-chip>
|
||||
<br>
|
||||
<span v-if="clinic.schedule" class="text-caption text-grey-darken-1">
|
||||
{{ clinic.schedule }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<div class="mt-3">
|
||||
<v-btn
|
||||
v-if="clinic.available"
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="small"
|
||||
block
|
||||
>
|
||||
Pilih Klinik
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
color="error"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled
|
||||
block
|
||||
>
|
||||
Tidak Tersedia
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredClinics.length === 0" class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-hospital-marker-outline</v-icon>
|
||||
<h3 class="text-h6 mt-4 text-grey-darken-1">Tidak ada klinik yang sesuai filter</h3>
|
||||
<p class="text-body-2 text-grey-darken-1">Coba ubah filter pencarian Anda</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Selection Dialog -->
|
||||
<v-dialog v-model="showDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center bg-primary text-white">
|
||||
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||||
Konfirmasi Pilihan
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6" v-if="selectedClinic">
|
||||
<div class="text-center">
|
||||
<div class="mb-3">
|
||||
<v-icon :icon="selectedClinic.icon" size="48" color="primary"></v-icon>
|
||||
</div>
|
||||
<h3 class="text-h5 font-weight-bold mb-2">{{ selectedClinic.name }}</h3>
|
||||
<p v-if="selectedClinic.subtitle" class="text-body-1 text-grey-darken-1 mb-3">
|
||||
{{ selectedClinic.subtitle }}
|
||||
</p>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<div class="text-left">
|
||||
<p><strong>Shift:</strong> {{ selectedClinic.shift }}</p>
|
||||
<p v-if="selectedClinic.schedule"><strong>Jadwal:</strong> {{ selectedClinic.schedule }}</p>
|
||||
<p><strong>Status:</strong> <span class="text-success">Tersedia</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn @click="showDialog = false" variant="text">
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="proceedToRegistration"
|
||||
variant="flat"
|
||||
>
|
||||
Lanjutkan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="3000"
|
||||
location="top right"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
|
||||
<template v-slot:actions>
|
||||
<v-btn icon @click="snackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
definePageMeta({
|
||||
layout: false, // Disables the layout for this specific page
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const loading = ref(false)
|
||||
const selectedStatus = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const showDialog = ref(false)
|
||||
const selectedClinic = ref(null)
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Options
|
||||
const statusOptions = ['Tersedia', 'Tutup']
|
||||
|
||||
// Clinic data
|
||||
const clinics = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'ANAK',
|
||||
subtitle: '',
|
||||
icon: 'mdi-baby-face',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'ANESTESI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-sleep',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'BEDAH',
|
||||
subtitle: '',
|
||||
icon: 'mdi-medical-bag',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'GERIATRI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-account-supervisor',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'GIGI DAN MULUT',
|
||||
subtitle: 'GIGI DAN MULUT',
|
||||
icon: 'mdi-tooth',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'GIZI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-food-apple',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'HOM',
|
||||
subtitle: 'HEMATO ONKOLOGI MEDIS',
|
||||
icon: 'mdi-water',
|
||||
shift: 'TUTUP',
|
||||
schedule: '',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'IPD',
|
||||
subtitle: 'PENYAKIT DALAM',
|
||||
icon: 'mdi-hospital',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'JANTUNG',
|
||||
subtitle: 'CARDIOLOGI',
|
||||
icon: 'mdi-heart-pulse',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'JIWA',
|
||||
subtitle: '',
|
||||
icon: 'mdi-brain',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'KANDUNGAN',
|
||||
subtitle: '',
|
||||
icon: 'mdi-human-pregnant',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'KEMOTERAPI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-needle',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'KOMPLEMENTER',
|
||||
subtitle: 'NYERI',
|
||||
icon: 'mdi-leaf',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'KUL KEL',
|
||||
subtitle: 'KULIT KELAMIN',
|
||||
icon: 'mdi-hand-back-right',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: 'MATA',
|
||||
subtitle: '',
|
||||
icon: 'mdi-eye',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
name: 'MCU',
|
||||
subtitle: '',
|
||||
icon: 'mdi-clipboard-check',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
name: 'ONKOLOGI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-ribbon',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
name: 'PARU',
|
||||
subtitle: '',
|
||||
icon: 'mdi-lungs',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
name: 'R. TINDAKAN',
|
||||
subtitle: 'EMG, ECG, DLL',
|
||||
icon: 'mdi-monitor-heart-rate',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
name: 'RADIOTERAPI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-radioactive',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
name: 'REHAB MEDIK',
|
||||
subtitle: '',
|
||||
icon: 'mdi-human-cane',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
name: 'SARAF',
|
||||
subtitle: 'NEUROLOGI',
|
||||
icon: 'mdi-head-cog',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
name: 'THT',
|
||||
subtitle: '',
|
||||
icon: 'mdi-ear-hearing',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
}
|
||||
])
|
||||
|
||||
// Computed properties
|
||||
const filteredClinics = computed(() => {
|
||||
let filtered = clinics.value
|
||||
|
||||
if (selectedStatus.value) {
|
||||
const isAvailable = selectedStatus.value === 'Tersedia'
|
||||
filtered = filtered.filter(clinic => clinic.available === isAvailable)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(clinic =>
|
||||
clinic.name.toLowerCase().includes(query) ||
|
||||
clinic.subtitle.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalClinics = computed(() => clinics.value.length)
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (text, color = 'success') => {
|
||||
snackbarText.value = text
|
||||
snackbarColor.value = color
|
||||
snackbar.value = true
|
||||
}
|
||||
|
||||
const selectClinic = (clinic) => {
|
||||
if (clinic.available) {
|
||||
selectedClinic.value = clinic
|
||||
showDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const proceedToRegistration = () => {
|
||||
showSnackbar(`Mengarahkan ke pendaftaran ${selectedClinic.value.name}...`, 'success')
|
||||
console.log('Proceeding to registration for:', selectedClinic.value.name)
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
showSnackbar('Status klinik berhasil diperbarui', 'success')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.anjungan-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.instruction-chip {
|
||||
font-weight: 500;
|
||||
color: #1976d2 !important;
|
||||
}
|
||||
|
||||
.controls-card,
|
||||
.main-content-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
cursor: pointer;
|
||||
border-radius: 16px !important;
|
||||
height: 280px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.clinic-available {
|
||||
border-left: 6px solid #4CAF50;
|
||||
}
|
||||
|
||||
.clinic-closed {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
border-left: 6px solid #F44336;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.clinic-icon-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.clinic-closed .clinic-icon-wrapper {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
.shift-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.anjungan-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.clinic-icon-wrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.clinic-card {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,352 @@
|
||||
<!-- pages/Anjungan/Anjungan/index.vue -->
|
||||
<template>
|
||||
<div class="anjungan-selection-container">
|
||||
<div class="selection-header">
|
||||
<div class="header-icon">
|
||||
<v-icon size="64" color="white">mdi-monitor-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1 class="main-title">Pilih Anjungan</h1>
|
||||
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anjungan-grid">
|
||||
<div
|
||||
v-for="anj in paginatedAnjungan"
|
||||
:key="anj.id"
|
||||
class="anjungan-card"
|
||||
@click="navigateToAnjungan(anj.id)"
|
||||
>
|
||||
<div class="anjungan-card-header">
|
||||
<v-icon size="32" color="primary">mdi-monitor</v-icon>
|
||||
<div class="anjungan-info">
|
||||
<h3 class="anjungan-name">{{ anj.namaAnjungan }}</h3>
|
||||
<p class="anjungan-type">{{ anj.jenisPasien }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anjungan-klinik-preview">
|
||||
<div class="klinik-count">
|
||||
<v-icon size="18" color="secondary">mdi-hospital-box</v-icon>
|
||||
<span>{{ anj.klinik.length }} Klinik</span>
|
||||
</div>
|
||||
<div class="klinik-tags">
|
||||
<v-chip
|
||||
v-for="(kode, idx) in anj.klinik.slice(0, 4)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
class="ma-1 chip-preview"
|
||||
>
|
||||
{{ masterStore.getKlinikNameByKode(kode) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="anj.klinik.length > 4"
|
||||
size="small"
|
||||
class="ma-1 chip-more"
|
||||
>
|
||||
+{{ anj.klinik.length - 4 }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anjungan-card-footer">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
class="btn-view"
|
||||
>
|
||||
<v-icon left>mdi-eye</v-icon>
|
||||
Tampilkan
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="anjunganList.length === 0" class="empty-state">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-monitor-off</v-icon>
|
||||
<h3>Tidak Ada Anjungan Tersedia</h3>
|
||||
<p>Silakan tambah anjungan terlebih dahulu di halaman master</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="navigateToSettings"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-icon left>mdi-cog</v-icon>
|
||||
Ke Halaman Master Anjungan
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalPages > 1" class="pagination">
|
||||
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
|
||||
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
|
||||
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAnjunganStore } from '@/stores/anjunganStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
const anjunganStore = useAnjunganStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const anjunganList = computed(() => {
|
||||
const fromGetter = anjunganStore.getAllAnjungan?.value;
|
||||
const fromState = anjunganStore.anjunganItems?.value || anjunganStore.anjunganItems || [];
|
||||
return Array.isArray(fromGetter) ? fromGetter : Array.isArray(fromState) ? fromState : [];
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const itemsPerPage = 10;
|
||||
const page = computed({
|
||||
get: () => Number(route.query.page || 1),
|
||||
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(anjunganList.value.length / itemsPerPage)));
|
||||
|
||||
const paginatedAnjungan = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage;
|
||||
return anjunganList.value.slice(start, start + itemsPerPage);
|
||||
});
|
||||
|
||||
const goPrev = () => {
|
||||
if (page.value > 1) page.value = page.value - 1;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (page.value < totalPages.value) page.value = page.value + 1;
|
||||
};
|
||||
|
||||
const navigateToAnjungan = (anjunganId) => {
|
||||
const id = typeof anjunganId === 'number' ? anjunganId : parseInt(anjunganId, 10);
|
||||
if (isNaN(id)) {
|
||||
console.error('Invalid anjungan ID:', anjunganId);
|
||||
return;
|
||||
}
|
||||
navigateTo(`/anjungan/anjungan/${id}`);
|
||||
};
|
||||
|
||||
const navigateToSettings = () => {
|
||||
navigateTo('/setting/masteranjungan');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.anjungan-selection-container {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: 0 8px 24px rgba(255, 155, 27, 0.3);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
color: var(--color-neutral-100);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--color-neutral-100);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.anjungan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.anjungan-card {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(6, 113, 224, 0.2);
|
||||
border-color: var(--color-secondary-600);
|
||||
}
|
||||
}
|
||||
|
||||
.anjungan-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.anjungan-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.anjungan-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.anjungan-type {
|
||||
font-size: 14px;
|
||||
color: var(--color-neutral-600);
|
||||
margin: 4px 0 0 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.anjungan-klinik-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.klinik-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-secondary-600);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.klinik-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip-preview {
|
||||
background-color: var(--color-secondary-300) !important;
|
||||
color: var(--color-secondary-700) !important;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-more {
|
||||
background-color: var(--color-neutral-600) !important;
|
||||
color: var(--color-neutral-100) !important;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.anjungan-card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--color-neutral-700);
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 24px 0 8px 0;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.anjungan-selection-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.anjungan-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
<!-- pages/AntrianKlinik.vue -->
|
||||
<template>
|
||||
<div class="tv-display-container">
|
||||
<!-- Header -->
|
||||
<div class="tv-header">
|
||||
<div class="tv-header-left">
|
||||
<div class="hospital-logo">
|
||||
<v-icon size="64" color="white">mdi-hospital-box</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="hospital-name">ANTRIAN KLINIK</h1>
|
||||
<p class="display-title">RSUD Dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-header-right">
|
||||
<div class="current-time">{{ currentTime }}</div>
|
||||
<div class="current-date">{{ currentDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Called Queue - Super Prominent -->
|
||||
<div v-if="currentCalledQueue" class="hero-queue">
|
||||
<div class="hero-label">SEDANG DIPANGGIL</div>
|
||||
<div class="hero-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
|
||||
<div class="hero-clinic">{{ currentCalledQueue.klinik }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Clinic Grid - 3x3 Layout -->
|
||||
<div class="clinic-grid">
|
||||
<div
|
||||
v-for="clinic in displayedClinics"
|
||||
:key="clinic.name"
|
||||
class="clinic-card"
|
||||
>
|
||||
<div class="clinic-name">{{ clinic.name }}</div>
|
||||
|
||||
<div class="queue-display">
|
||||
<!-- Show current queue number -->
|
||||
<div v-if="clinic.currentQueue" class="current-queue-large">
|
||||
<div class="queue-label">SEKARANG</div>
|
||||
<div class="queue-number-huge">
|
||||
{{ clinic.currentQueue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show next queues -->
|
||||
<div v-if="clinic.nextQueues.length > 0" class="next-queues">
|
||||
<div class="queue-label-small">SELANJUTNYA</div>
|
||||
<div class="next-queue-numbers">
|
||||
<span
|
||||
v-for="(queue, index) in clinic.nextQueues.slice(0, 3)"
|
||||
:key="queue.no"
|
||||
class="next-number"
|
||||
>
|
||||
{{ queue.noAntrian.split(' |')[0] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!clinic.currentQueue && clinic.nextQueues.length === 0" class="empty-queue">
|
||||
<v-icon size="48" color="grey-lighten-2">mdi-checkbox-blank-circle-outline</v-icon>
|
||||
<div class="empty-text">Tidak Ada Antrian</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue count badge -->
|
||||
<div class="queue-count">
|
||||
<v-icon size="20" class="mr-1">mdi-account-multiple</v-icon>
|
||||
{{ clinic.totalQueues }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="tv-footer">
|
||||
<div class="footer-stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{{ statistics.total }}</div>
|
||||
<div class="stat-label">Total Antrian</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{{ statistics.waiting }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{{ statistics.active }}</div>
|
||||
<div class="stat-label">Sedang Dilayani</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-message">
|
||||
<v-icon size="24" class="mr-2">mdi-information</v-icon>
|
||||
Harap perhatikan nomor antrian Anda di layar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useQueueStore } from '../stores/queueStore'
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
// Store
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
// Reactive data
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
let refreshInterval = null
|
||||
|
||||
// Computed - Get all klinik patients (stage: klinik)
|
||||
const klinikPatients = computed(() => {
|
||||
return queueStore.getPatientsByStage('klinik').value.all
|
||||
})
|
||||
|
||||
// Get clinics with their queues organized
|
||||
const displayedClinics = computed(() => {
|
||||
const allKliniks = queueStore.kliniks.slice(0, 9) // Max 9 clinics for 3x3 grid
|
||||
|
||||
return allKliniks.map(klinik => {
|
||||
const queues = klinikPatients.value
|
||||
.filter(p => p.klinik === klinik.name)
|
||||
.sort((a, b) => {
|
||||
// Sort by status priority then time
|
||||
const statusPriority = {
|
||||
'di-loket': 1,
|
||||
'waiting': 2,
|
||||
'terlambat': 3,
|
||||
'pending': 4
|
||||
}
|
||||
|
||||
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
|
||||
// Sort by time if same status
|
||||
const timeA = a.jamPanggil.split(':').map(Number)
|
||||
const timeB = b.jamPanggil.split(':').map(Number)
|
||||
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
|
||||
})
|
||||
|
||||
// Get current (first di-loket or first waiting)
|
||||
const currentQueue = queues.find(q => q.status === 'di-loket') ||
|
||||
(queues.find(q => q.status === 'waiting') || null)
|
||||
|
||||
// Get next queues (excluding current)
|
||||
const nextQueues = queues.filter(q =>
|
||||
q.no !== currentQueue?.no &&
|
||||
(q.status === 'waiting' || q.status === 'di-loket')
|
||||
)
|
||||
|
||||
return {
|
||||
name: klinik.name,
|
||||
currentQueue: currentQueue,
|
||||
nextQueues: nextQueues,
|
||||
totalQueues: queues.length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Current queue being called (most recent di-loket)
|
||||
const currentCalledQueue = computed(() => {
|
||||
const calledQueues = klinikPatients.value
|
||||
.filter(p => p.status === 'di-loket')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
return calledQueues[0] || null
|
||||
})
|
||||
|
||||
// Statistics
|
||||
const statistics = computed(() => {
|
||||
const all = klinikPatients.value
|
||||
return {
|
||||
total: all.length,
|
||||
waiting: all.filter(p => p.status === 'waiting').length,
|
||||
active: all.filter(p => p.status === 'di-loket').length
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
currentDate.value = now.toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
|
||||
// Auto refresh every 5 seconds
|
||||
refreshInterval = setInterval(() => {
|
||||
// Data will auto-update due to reactive store
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tv-display-container {
|
||||
background: linear-gradient(135deg, #e7e7e7 0%, #FFDCAF 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.tv-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #FAFAFA;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 20px 40px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tv-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hospital-logo {
|
||||
background: #1e3c72;
|
||||
border-radius: 50%;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 36px;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
color: #1e3c72;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.display-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #1e3c72;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tv-header-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #1e3c72;
|
||||
text-shadow: 2px 2px 4px rgba(124, 124, 124, 0.3);
|
||||
}
|
||||
|
||||
.current-date {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Hero Queue - Sedang Dipanggil */
|
||||
.hero-queue {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||
border-radius: 24px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-number {
|
||||
font-size: 120px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin: 10px 0;
|
||||
text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.hero-clinic {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 16px 64px rgba(255, 107, 107, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clinic Grid - 3x3 */
|
||||
.clinic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #1e3c72;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 3px solid #2a5298;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.queue-display {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Current Queue Display */
|
||||
.current-queue-large {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.queue-label {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #4caf50;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.queue-number-huge {
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e3c72;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Next Queues */
|
||||
.next-queues {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.queue-label-small {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-queue-numbers {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.next-number {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-queue {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Queue Count Badge */
|
||||
.queue-count {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.tv-footer {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 20px 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.footer-stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color:#1e3c72;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
margin-top: 5px;
|
||||
color:#1e3c72;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.95;
|
||||
color:#1e3c72;
|
||||
}
|
||||
|
||||
/* Responsive for different TV sizes */
|
||||
@media (max-width: 1920px) {
|
||||
.hero-number {
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.queue-number-huge {
|
||||
font-size: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.clinic-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
padding: 15px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.queue-number-huge {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.hero-number {
|
||||
font-size: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for smooth transitions */
|
||||
.clinic-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.queue-number-huge,
|
||||
.hero-number {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,356 @@
|
||||
<!-- pages/Anjungan/AntrianKlinik/index.vue -->
|
||||
<template>
|
||||
<div class="screen-selection-container">
|
||||
<div class="selection-header">
|
||||
<div class="header-icon">
|
||||
<v-icon size="64" color="white">mdi-monitor-multiple</v-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1 class="main-title">Pilih Layar Antrian Klinik</h1>
|
||||
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screens-grid">
|
||||
<div
|
||||
v-for="screen in paginatedScreens"
|
||||
:key="screen.id"
|
||||
class="screen-card"
|
||||
@click="navigateToScreen(screen.id)"
|
||||
>
|
||||
<div class="screen-card-header">
|
||||
<v-icon size="32" color="primary">mdi-monitor</v-icon>
|
||||
<div class="screen-info">
|
||||
<h3 class="screen-name">{{ screen.namaScreen }}</h3>
|
||||
<p class="screen-number">{{ screen.nomorScreen }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen-klinik-preview">
|
||||
<div class="klinik-count">
|
||||
<v-icon size="18" color="secondary">mdi-hospital-box</v-icon>
|
||||
<span>{{ screen.klinik.length }} Klinik</span>
|
||||
</div>
|
||||
<div class="klinik-tags">
|
||||
<v-chip
|
||||
v-for="(kode, idx) in screen.klinik.slice(0, 4)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
class="ma-1 chip-preview"
|
||||
>
|
||||
{{ masterStore.getKlinikNameByKode(kode) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="screen.klinik.length > 4"
|
||||
size="small"
|
||||
class="ma-1 chip-more"
|
||||
>
|
||||
+{{ screen.klinik.length - 4 }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen-card-footer">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
class="btn-view"
|
||||
>
|
||||
<v-icon left>mdi-eye</v-icon>
|
||||
Tampilkan
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="screens.length === 0" class="empty-state">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-monitor-off</v-icon>
|
||||
<h3>Tidak Ada Screen Tersedia</h3>
|
||||
<p>Silakan tambah screen terlebih dahulu di halaman konfigurasi</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="navigateToSettings"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-icon left>mdi-cog</v-icon>
|
||||
Ke Halaman Konfigurasi
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalPages > 1" class="pagination">
|
||||
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
|
||||
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
|
||||
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useScreenStore } from '@/stores/screenStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
const screenStore = useScreenStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const screens = computed(() => {
|
||||
const fromGetter = screenStore.getAllScreens?.value;
|
||||
const fromState = screenStore.screenItems?.value || screenStore.screenItems || [];
|
||||
return Array.isArray(fromGetter) ? fromGetter : Array.isArray(fromState) ? fromState : [];
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const itemsPerPage = 10;
|
||||
const page = computed({
|
||||
get: () => Number(route.query.page || 1),
|
||||
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(screens.value.length / itemsPerPage)));
|
||||
|
||||
const paginatedScreens = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage;
|
||||
return screens.value.slice(start, start + itemsPerPage);
|
||||
});
|
||||
|
||||
const goPrev = () => {
|
||||
if (page.value > 1) page.value = page.value - 1;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (page.value < totalPages.value) page.value = page.value + 1;
|
||||
};
|
||||
|
||||
const navigateToScreen = (screenId) => {
|
||||
const id = typeof screenId === 'number' ? screenId : parseInt(screenId, 10);
|
||||
if (isNaN(id)) {
|
||||
console.error('Invalid screen ID:', screenId);
|
||||
return;
|
||||
}
|
||||
|
||||
// gunakan path langsung agar tidak tergantung nama route auto-gen
|
||||
navigateTo(`/anjungan/antrianklinik/${id}`);
|
||||
};
|
||||
|
||||
const navigateToSettings = () => {
|
||||
navigateTo('/setting/screen');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.screen-selection-container {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: 0 8px 24px rgba(255, 155, 27, 0.3);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
color: var(--color-neutral-100);
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--color-neutral-100);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.screens-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.screen-card {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(6, 113, 224, 0.2);
|
||||
border-color: var(--color-secondary-600);
|
||||
}
|
||||
}
|
||||
|
||||
.screen-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.screen-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.screen-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.screen-number {
|
||||
font-size: 14px;
|
||||
color: var(--color-neutral-600);
|
||||
margin: 4px 0 0 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.screen-klinik-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.klinik-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-secondary-600);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.klinik-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip-preview {
|
||||
background-color: var(--color-secondary-300) !important;
|
||||
color: var(--color-secondary-700) !important;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-more {
|
||||
background-color: var(--color-neutral-600) !important;
|
||||
color: var(--color-neutral-100) !important;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.screen-card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--color-neutral-700);
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 24px 0 8px 0;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.screen-selection-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.screens-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
<!-- pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue -->
|
||||
<template>
|
||||
<div class="antrian-display-container">
|
||||
<!-- Header -->
|
||||
<div class="display-header">
|
||||
<div class="header-left">
|
||||
<div class="logo-circle">
|
||||
<v-icon size="48" color="white">mdi-door-open</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="hospital-name">ANTRIAN KLINIK RUANG</h1>
|
||||
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
<p v-if="klinikData" class="klinik-info">{{ klinikData.namaKlinik }} ({{ klinikData.kodeKlinik }})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="datetime-display">
|
||||
<div class="time-large">{{ currentTime }}</div>
|
||||
<div class="date-small">{{ currentDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Call Section -->
|
||||
<div v-if="currentCalledQueue" class="hero-call-section">
|
||||
<div class="call-label">
|
||||
<v-icon size="32" color="white" class="mr-2">mdi-bullhorn</v-icon>
|
||||
NOMOR ANTRIAN YANG DIPANGGIL
|
||||
</div>
|
||||
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
|
||||
<div class="call-clinic">{{ currentCalledQueue.klinik }} - {{ currentCalledQueue.ruang }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Grid - Display Ruang -->
|
||||
<div class="clinics-grid">
|
||||
<div
|
||||
v-for="ruang in displayedRuang"
|
||||
:key="`${ruang.kodeKlinik}-${ruang.nomorRuang}`"
|
||||
class="clinic-box"
|
||||
>
|
||||
<!-- Clinic Header -->
|
||||
<div class="clinic-header-bar">
|
||||
<span class="clinic-title">{{ ruang.namaKlinik }} - {{ ruang.namaRuang }}</span>
|
||||
<v-chip size="small" class="clinic-count">
|
||||
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
|
||||
<span class="count-text">{{ ruang.totalQueues }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Queue Content -->
|
||||
<div class="clinic-content">
|
||||
<!-- Current Queue -->
|
||||
<div v-if="ruang.currentQueue" class="current-section">
|
||||
<div class="current-label">SEKARANG</div>
|
||||
<div class="current-number">
|
||||
{{ ruang.currentQueue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Queues -->
|
||||
<div v-if="ruang.nextQueues.length > 0" class="next-section">
|
||||
<div class="next-label">SELANJUTNYA</div>
|
||||
<div class="next-numbers">
|
||||
<div
|
||||
v-for="queue in ruang.nextQueues.slice(0, 3)"
|
||||
:key="queue.no"
|
||||
class="next-item"
|
||||
>
|
||||
{{ queue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!ruang.currentQueue && ruang.nextQueues.length === 0" class="empty-state">
|
||||
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
|
||||
<p class="empty-text">Tidak Ada Antrian</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div class="footer-stats-bar">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon stat-icon-total">
|
||||
<v-icon size="32" color="success-600">mdi-format-list-numbered</v-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.total }}</div>
|
||||
<div class="stat-label">Total Antrian</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-divider"></div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon stat-icon-waiting">
|
||||
<v-icon size="32" color="success-600">mdi-clock-alert-outline</v-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.waiting }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-divider"></div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon stat-icon-active">
|
||||
<v-icon size="32" color="success-600">mdi-account-check</v-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.active }}</div>
|
||||
<div class="stat-label">Sedang Dilayani</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-message">
|
||||
<v-icon size="24" color="success-600" class="mr-2">mdi-information</v-icon>
|
||||
<span>Harap perhatikan nomor antrian Anda di layar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useMasterStore } from '@/stores/masterStore'
|
||||
import { useRoute } from '#app'
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const queueStore = useQueueStore()
|
||||
const masterStore = useMasterStore()
|
||||
|
||||
const kodeKlinik = computed(() => {
|
||||
const kode = route.params.kodeKlinik
|
||||
// Handle both string and array cases
|
||||
return Array.isArray(kode) ? kode[0] : kode
|
||||
})
|
||||
|
||||
const klinikData = computed(() => {
|
||||
const kode = kodeKlinik.value
|
||||
if (!kode) return null
|
||||
|
||||
const fromGetter = masterStore.getAllRuangList?.value
|
||||
const fromState = masterStore.ruangData?.value || masterStore.ruangData || []
|
||||
|
||||
let allRuang = Array.isArray(fromGetter) ? fromGetter : []
|
||||
if (allRuang.length === 0 && Array.isArray(fromState)) {
|
||||
fromState.forEach((r) => {
|
||||
(r?.ruangList || []).forEach((ru) => {
|
||||
allRuang.push({
|
||||
kodeKlinik: r.kodeKlinik,
|
||||
namaKlinik: r.namaKlinik,
|
||||
nomorRuang: ru.nomorRuang,
|
||||
namaRuang: ru.namaRuang,
|
||||
nomorScreen: ru.nomorScreen,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const firstRuang = allRuang.find(r => r.kodeKlinik === kode)
|
||||
if (firstRuang) {
|
||||
return {
|
||||
kodeKlinik: firstRuang.kodeKlinik,
|
||||
namaKlinik: firstRuang.namaKlinik
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
|
||||
const klinikPatients = computed(() => {
|
||||
return queueStore.getPatientsByStage('klinik').value.all
|
||||
})
|
||||
|
||||
// Get ruang list for this specific klinik
|
||||
const ruangListForKlinik = computed(() => {
|
||||
if (!kodeKlinik.value || !klinikData.value) return []
|
||||
|
||||
const fromGetter = masterStore.getAllRuangList?.value
|
||||
const fromState = masterStore.ruangData?.value || masterStore.ruangData || []
|
||||
let allRuang = Array.isArray(fromGetter) ? fromGetter : []
|
||||
if (allRuang.length === 0 && Array.isArray(fromState)) {
|
||||
fromState.forEach((r) => {
|
||||
(r?.ruangList || []).forEach((ru) => {
|
||||
allRuang.push({
|
||||
kodeKlinik: r.kodeKlinik,
|
||||
namaKlinik: r.namaKlinik,
|
||||
nomorRuang: ru.nomorRuang,
|
||||
namaRuang: ru.namaRuang,
|
||||
nomorScreen: ru.nomorScreen,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
return allRuang
|
||||
.filter(r => r.kodeKlinik === kodeKlinik.value)
|
||||
.map(ruang => ({
|
||||
kodeKlinik: ruang.kodeKlinik,
|
||||
namaKlinik: ruang.namaKlinik,
|
||||
nomorRuang: ruang.nomorRuang,
|
||||
namaRuang: ruang.namaRuang,
|
||||
nomorScreen: ruang.nomorScreen
|
||||
}))
|
||||
})
|
||||
|
||||
const displayedRuang = computed(() => {
|
||||
if (!kodeKlinik.value || ruangListForKlinik.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return ruangListForKlinik.value.map(ruang => {
|
||||
const queues = klinikPatients.value
|
||||
.filter(p => p.klinik === ruang.namaKlinik)
|
||||
.sort((a, b) => {
|
||||
const statusPriority = {
|
||||
'di-loket': 1,
|
||||
'waiting': 2,
|
||||
'terlambat': 3,
|
||||
'pending': 4
|
||||
}
|
||||
|
||||
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
|
||||
const timeA = a.jamPanggil.split(':').map(Number)
|
||||
const timeB = b.jamPanggil.split(':').map(Number)
|
||||
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
|
||||
})
|
||||
|
||||
const currentQueue = queues.find(q => q.status === 'di-loket') ||
|
||||
(queues.find(q => q.status === 'waiting') || null)
|
||||
|
||||
const nextQueues = queues.filter(q =>
|
||||
q.no !== currentQueue?.no &&
|
||||
(q.status === 'waiting' || q.status === 'di-loket')
|
||||
)
|
||||
|
||||
return {
|
||||
kodeKlinik: ruang.kodeKlinik,
|
||||
namaKlinik: ruang.namaKlinik,
|
||||
nomorRuang: ruang.nomorRuang,
|
||||
namaRuang: ruang.namaRuang,
|
||||
nomorScreen: ruang.nomorScreen,
|
||||
currentQueue: currentQueue ? { ...currentQueue, ruang: ruang.namaRuang } : null,
|
||||
nextQueues: nextQueues,
|
||||
totalQueues: queues.length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const currentCalledQueue = computed(() => {
|
||||
if (!kodeKlinik.value || !klinikData.value) return null
|
||||
|
||||
const calledQueues = klinikPatients.value
|
||||
.filter(p => p.status === 'di-loket' && p.klinik === klinikData.value.namaKlinik)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
if (calledQueues.length > 0) {
|
||||
const patient = calledQueues[0]
|
||||
const ruang = ruangListForKlinik.value.find(r => r.namaKlinik === patient.klinik)
|
||||
return {
|
||||
...patient,
|
||||
ruang: ruang ? ruang.namaRuang : ruangListForKlinik.value[0]?.namaRuang || 'Ruang 1'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const statistics = computed(() => {
|
||||
if (!kodeKlinik.value || !klinikData.value) {
|
||||
return { total: 0, waiting: 0, active: 0 }
|
||||
}
|
||||
|
||||
const all = klinikPatients.value.filter(p => p.klinik === klinikData.value.namaKlinik)
|
||||
return {
|
||||
total: all.length,
|
||||
waiting: all.filter(p => p.status === 'waiting').length,
|
||||
active: all.filter(p => p.status === 'di-loket').length
|
||||
}
|
||||
})
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
currentDate.value = now.toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Redirect to index if klinik not found
|
||||
if (!klinikData.value) {
|
||||
navigateTo('/anjungan/antrianklinikruang')
|
||||
return
|
||||
}
|
||||
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.antrian-display-container {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
max-width: 1920px;
|
||||
max-height: 1080px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 24px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ========== HEADER ========== */
|
||||
.display-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
|
||||
border-radius: 16px;
|
||||
padding: 24px 40px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 146, 98, 0.3);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.logo-circle {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-100);
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.display-subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-100);
|
||||
margin: 6px 0 0 0;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.klinik-info {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-100);
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.datetime-display {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 56px;
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-100);
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.date-small {
|
||||
font-size: 18px;
|
||||
color: var(--color-neutral-100);
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* ========== HERO CALL SECTION ========== */
|
||||
.hero-call-section {
|
||||
background: linear-gradient(135deg, var(--color-danger-600) 0%, var(--color-danger-700) 100%);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
|
||||
}
|
||||
|
||||
.call-label {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-100);
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 140px;
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-100);
|
||||
margin: 16px 0;
|
||||
letter-spacing: 8px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.call-clinic {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-100);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ========== CLINICS GRID ========== */
|
||||
.clinics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clinic-box {
|
||||
background: var(--color-neutral-100);
|
||||
border: 2px solid var(--color-success-200);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 146, 98, 0.12);
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clinic-header-bar {
|
||||
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clinic-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-100);
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clinic-count {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
color: var(--color-neutral-100) !important;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.count-text {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.clinic-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--color-success-100);
|
||||
}
|
||||
|
||||
.current-section {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-success-600);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.current-number {
|
||||
font-size: 80px;
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-900);
|
||||
letter-spacing: 4px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.next-section {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.next-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-numbers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.next-item {
|
||||
background: var(--color-success-600);
|
||||
color: var(--color-neutral-100);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 146, 98, 0.25);
|
||||
letter-spacing: 1px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========== FOOTER STATS ========== */
|
||||
.footer-stats-bar {
|
||||
background: var(--color-neutral-100);
|
||||
border: 2px solid var(--color-success-200);
|
||||
border-radius: 16px;
|
||||
padding: 20px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: 0 4px 12px rgba(0, 146, 98, 0.12);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon-total,
|
||||
.stat-icon-waiting {
|
||||
background: var(--color-success-200);
|
||||
}
|
||||
|
||||
.stat-icon-active {
|
||||
background: var(--color-success-200);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 40px;
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-neutral-700);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 2px;
|
||||
height: 60px;
|
||||
background: var(--color-success-200);
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-800);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ========== RESPONSIVE ========== */
|
||||
@media (min-width: 1920px) and (min-height: 1080px) {
|
||||
.antrian-display-container {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.antrian-display-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.display-header {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.logo-circle {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo-circle .v-icon {
|
||||
font-size: 40px !important;
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.display-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.date-small {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hero-call-section {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.call-label {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.call-clinic {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.clinics-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.clinic-box {
|
||||
min-height: 220px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clinic-header-bar {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.clinic-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.clinic-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.current-number {
|
||||
font-size: 72px;
|
||||
}
|
||||
|
||||
.next-item {
|
||||
font-size: 20px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.footer-stats-bar {
|
||||
padding: 16px 32px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.stat-icon .v-icon {
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.display-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.datetime-display {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clinics-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.footer-stats-bar {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clinics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.current-number {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent text selection */
|
||||
* {
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<!-- pages/Anjungan/AntrianKlinikRuang/index.vue -->
|
||||
<template>
|
||||
<div class="klinik-selection-container">
|
||||
<div class="selection-header">
|
||||
<div class="header-icon">
|
||||
<v-icon size="64" color="white">mdi-door-open</v-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1 class="main-title">Pilih Klinik Ruang</h1>
|
||||
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kliniks-grid" v-if="paginatedKliniks.length > 0">
|
||||
<div
|
||||
v-for="klinik in paginatedKliniks"
|
||||
:key="klinik.kodeKlinik"
|
||||
class="klinik-card"
|
||||
@click="navigateToKlinik(klinik.kodeKlinik)"
|
||||
>
|
||||
<div class="klinik-card-header">
|
||||
<v-icon size="32" color="success">mdi-hospital-building</v-icon>
|
||||
<div class="klinik-info">
|
||||
<h3 class="klinik-name">{{ klinik.namaKlinik }}</h3>
|
||||
<p class="klinik-code">{{ klinik.kodeKlinik }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="klinik-ruang-preview">
|
||||
<div class="ruang-count">
|
||||
<v-icon size="18" color="success">mdi-door</v-icon>
|
||||
<span>{{ klinik.ruangList.length }} Ruang</span>
|
||||
</div>
|
||||
<div class="ruang-tags">
|
||||
<v-chip
|
||||
v-for="(ruang, idx) in klinik.ruangList.slice(0, 3)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
class="ma-1 chip-preview"
|
||||
>
|
||||
{{ ruang.namaRuang }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="klinik.ruangList.length > 3"
|
||||
size="small"
|
||||
class="ma-1 chip-more"
|
||||
>
|
||||
+{{ klinik.ruangList.length - 3 }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="klinik-card-footer">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
class="btn-view"
|
||||
>
|
||||
<v-icon left>mdi-eye</v-icon>
|
||||
Tampilkan
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-hospital-building-off</v-icon>
|
||||
<h3>Tidak Ada Klinik dengan Ruang Tersedia</h3>
|
||||
<p>Silakan tambah ruang klinik terlebih dahulu di halaman master</p>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="navigateToSettings"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-icon left>mdi-cog</v-icon>
|
||||
Ke Halaman Master
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="paginatedKliniks.length > 0 && totalPages > 1" class="pagination">
|
||||
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
|
||||
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
|
||||
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Group klinik berdasarkan data flatten getAllRuangList agar nama ruang baru langsung muncul
|
||||
const kliniksWithRuang = computed(() => {
|
||||
const fromGetter = masterStore.getAllRuangList?.value;
|
||||
const fromState = masterStore.ruangData?.value || masterStore.ruangData || [];
|
||||
|
||||
let allRuang = Array.isArray(fromGetter) ? fromGetter : [];
|
||||
|
||||
// Jika getter kosong, flatten dari ruangData (grouped)
|
||||
if (allRuang.length === 0 && Array.isArray(fromState)) {
|
||||
fromState.forEach((r) => {
|
||||
(r?.ruangList || []).forEach((ru) => {
|
||||
allRuang.push({
|
||||
kodeKlinik: r.kodeKlinik,
|
||||
namaKlinik: r.namaKlinik,
|
||||
nomorRuang: ru.nomorRuang,
|
||||
namaRuang: ru.namaRuang,
|
||||
nomorScreen: ru.nomorScreen,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const klinikMap = {};
|
||||
allRuang.forEach((ruang) => {
|
||||
if (!klinikMap[ruang.kodeKlinik]) {
|
||||
klinikMap[ruang.kodeKlinik] = {
|
||||
kodeKlinik: ruang.kodeKlinik,
|
||||
namaKlinik: ruang.namaKlinik,
|
||||
ruangList: [],
|
||||
};
|
||||
}
|
||||
klinikMap[ruang.kodeKlinik].ruangList.push({
|
||||
namaRuang: ruang.namaRuang,
|
||||
nomorRuang: ruang.nomorRuang,
|
||||
nomorScreen: ruang.nomorScreen,
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(klinikMap);
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const itemsPerPage = 10;
|
||||
const page = computed({
|
||||
get: () => Number(route.query.page || 1),
|
||||
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(kliniksWithRuang.value.length / itemsPerPage)));
|
||||
|
||||
const paginatedKliniks = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage;
|
||||
return kliniksWithRuang.value.slice(start, start + itemsPerPage);
|
||||
});
|
||||
|
||||
const goPrev = () => {
|
||||
if (page.value > 1) page.value = page.value - 1;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (page.value < totalPages.value) page.value = page.value + 1;
|
||||
};
|
||||
|
||||
const navigateToKlinik = (kodeKlinik) => {
|
||||
navigateTo(`/anjungan/antrianklinikruang/${kodeKlinik}`);
|
||||
};
|
||||
|
||||
const navigateToSettings = () => {
|
||||
navigateTo('/setting/masterklinikruang');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.klinik-selection-container {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: 0 8px 24px rgba(0, 146, 98, 0.3);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
color: var(--color-neutral-100);
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--color-neutral-100);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.kliniks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.klinik-card {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 146, 98, 0.2);
|
||||
border-color: var(--color-success-600);
|
||||
}
|
||||
}
|
||||
|
||||
.klinik-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.klinik-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.klinik-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.klinik-code {
|
||||
font-size: 14px;
|
||||
color: var(--color-neutral-600);
|
||||
margin: 4px 0 0 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.klinik-ruang-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ruang-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-success-600);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ruang-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip-preview {
|
||||
background-color: var(--color-success-300) !important;
|
||||
color: var(--color-success-700) !important;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-more {
|
||||
background-color: var(--color-neutral-600) !important;
|
||||
color: var(--color-neutral-100) !important;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.klinik-card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--color-neutral-700);
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 24px 0 8px 0;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.klinik-selection-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.kliniks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
<!-- pages/AntrianPenunjang.vue -->
|
||||
<template>
|
||||
<div class="antrian-display-container">
|
||||
<!-- Header -->
|
||||
<div class="display-header">
|
||||
<div class="header-left">
|
||||
<div class="logo-circle">
|
||||
<v-icon size="48" color="white">mdi-clipboard-pulse</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="hospital-name">RUMAH SAKIT</h1>
|
||||
<p class="display-subtitle">Antrian Penunjang</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="datetime-display">
|
||||
<div class="time-large">{{ currentTime }}</div>
|
||||
<div class="date-small">{{ currentDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Call Section -->
|
||||
<div v-if="currentCalledQueue" class="hero-call-section">
|
||||
<div class="call-label">
|
||||
<v-icon size="32" class="mr-2">mdi-bullhorn</v-icon>
|
||||
NOMOR ANTRIAN YANG DIPANGGIL
|
||||
</div>
|
||||
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
|
||||
<div class="call-clinic">{{ currentCalledQueue.klinik }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Grid - 3x3 Penunjang -->
|
||||
<div class="clinics-grid">
|
||||
<div
|
||||
v-for="penunjang in displayedPenunjangs"
|
||||
:key="penunjang.name"
|
||||
class="clinic-box"
|
||||
>
|
||||
<!-- Penunjang Header -->
|
||||
<div class="clinic-header-bar" style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);">
|
||||
<span class="clinic-title">{{ penunjang.name }}</span>
|
||||
<v-chip
|
||||
:color="penunjang.totalQueues > 0 ? '#9C27B0' : '#89939E'"
|
||||
size="small"
|
||||
class="clinic-count"
|
||||
>
|
||||
<v-icon size="16" class="mr-1">mdi-account-multiple</v-icon>
|
||||
{{ penunjang.totalQueues }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Queue Content -->
|
||||
<div class="clinic-content">
|
||||
<!-- Current Queue -->
|
||||
<div v-if="penunjang.currentQueue" class="current-section">
|
||||
<div class="current-label">SEKARANG</div>
|
||||
<div class="current-number">
|
||||
{{ penunjang.currentQueue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Queues -->
|
||||
<div v-if="penunjang.nextQueues.length > 0" class="next-section">
|
||||
<div class="next-label">SELANJUTNYA</div>
|
||||
<div class="next-numbers">
|
||||
<div
|
||||
v-for="queue in penunjang.nextQueues.slice(0, 3)"
|
||||
:key="queue.no"
|
||||
class="next-item"
|
||||
style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);"
|
||||
>
|
||||
{{ queue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!penunjang.currentQueue && penunjang.nextQueues.length === 0" class="empty-state">
|
||||
<v-icon size="56" color="#F5F7FA">mdi-clock-outline</v-icon>
|
||||
<p class="empty-text">Tidak Ada Antrian</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div class="footer-stats-bar">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon" style="background: #F3E5F5;">
|
||||
<v-icon size="32" color="#9C27B0">mdi-format-list-numbered</v-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.total }}</div>
|
||||
<div class="stat-label">Total Antrian</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-divider"></div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon" style="background: #F3E5F5;">
|
||||
<v-icon size="32" color="#9C27B0">mdi-clock-alert-outline</v-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.waiting }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-divider"></div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon" style="background: #E8F5E9;">
|
||||
<v-icon size="32" color="#18B653">mdi-account-check</v-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.active }}</div>
|
||||
<div class="stat-label">Sedang Dilayani</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-message">
|
||||
<v-icon size="24" color="#9C27B0" class="mr-2">mdi-information</v-icon>
|
||||
<span>Harap perhatikan nomor antrian Anda di layar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useQueueStore } from '../stores/queueStore'
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
|
||||
const penunjangPatients = computed(() => {
|
||||
return queueStore.getPatientsByStage('penunjang').value.all
|
||||
})
|
||||
|
||||
const displayedPenunjangs = computed(() => {
|
||||
const allPenunjangs = queueStore.penunjangs.slice(0, 9)
|
||||
|
||||
return allPenunjangs.map(penunjang => {
|
||||
const queues = penunjangPatients.value
|
||||
.filter(p => p.klinik === penunjang.name)
|
||||
.sort((a, b) => {
|
||||
const statusPriority = {
|
||||
'di-loket': 1,
|
||||
'waiting': 2,
|
||||
'terlambat': 3,
|
||||
'pending': 4
|
||||
}
|
||||
|
||||
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
|
||||
const timeA = a.jamPanggil.split(':').map(Number)
|
||||
const timeB = b.jamPanggil.split(':').map(Number)
|
||||
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
|
||||
})
|
||||
|
||||
const currentQueue = queues.find(q => q.status === 'di-loket') ||
|
||||
(queues.find(q => q.status === 'waiting') || null)
|
||||
|
||||
const nextQueues = queues.filter(q =>
|
||||
q.no !== currentQueue?.no &&
|
||||
(q.status === 'waiting' || q.status === 'di-loket')
|
||||
)
|
||||
|
||||
return {
|
||||
name: penunjang.name,
|
||||
currentQueue: currentQueue,
|
||||
nextQueues: nextQueues,
|
||||
totalQueues: queues.length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const currentCalledQueue = computed(() => {
|
||||
const calledQueues = penunjangPatients.value
|
||||
.filter(p => p.status === 'di-loket')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
return calledQueues[0] || null
|
||||
})
|
||||
|
||||
const statistics = computed(() => {
|
||||
const all = penunjangPatients.value
|
||||
return {
|
||||
total: all.length,
|
||||
waiting: all.filter(p => p.status === 'waiting').length,
|
||||
active: all.filter(p => p.status === 'di-loket').length
|
||||
}
|
||||
})
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
currentDate.value = now.toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.antrian-display-container {
|
||||
background: #FFFFFF;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.display-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
padding: 24px 40px;
|
||||
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 24px rgba(255, 252, 255, 0.2);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo-circle {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
color: #FFFFFF;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.display-subtitle {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
margin: 0;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.date-small {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
margin-top: 8px;
|
||||
font-weight: 400;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.hero-call-section {
|
||||
background: linear-gradient(135deg, #E01507 0%, #E02B1D 100%);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
|
||||
animation: pulse-shadow 2s infinite;
|
||||
}
|
||||
|
||||
.call-label {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 110px;
|
||||
font-weight: 900;
|
||||
color: #FFFFFF;
|
||||
line-height: 1;
|
||||
margin: 16px 0;
|
||||
letter-spacing: 4px;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.call-clinic {
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@keyframes pulse-shadow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 16px 48px rgba(224, 21, 7, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.clinics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.clinic-box {
|
||||
background: #FFFFFF;
|
||||
border: 2px solid #F3E5F5;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(156, 39, 176, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clinic-box:hover {
|
||||
box-shadow: 0 8px 24px rgba(156, 39, 176, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.clinic-header-bar {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clinic-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.clinic-count {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.clinic-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: #FAFBFC;
|
||||
}
|
||||
|
||||
.current-section {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #18B653;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.current-number {
|
||||
font-size: 68px;
|
||||
font-weight: 900;
|
||||
color: #212121;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.next-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.next-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #717171;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-numbers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.next-item {
|
||||
color: #FFFFFF;
|
||||
padding: 8px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.2);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
color: #89939E;
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-stats-bar {
|
||||
background: #FFFFFF;
|
||||
border: 2px solid #F3E5F5;
|
||||
border-radius: 20px;
|
||||
padding: 24px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: 0 4px 16px rgba(156, 39, 176, 0.08);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
color: #212121;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 15px;
|
||||
color: #717171;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 2px;
|
||||
height: 64px;
|
||||
background: #F3E5F5;
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #4D4D4D;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,858 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-clipboard-plus</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="page-title">Buat Antrean</h2>
|
||||
<p class="page-subtitle">{{ currentDate }} - Manajemen Pembuatan Antrean</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-chip color="white" variant="flat" class="stat-chip">
|
||||
<v-icon start size="20" color="primary">mdi-account-multiple</v-icon>
|
||||
<span class="text-semibold">{{ totalPasien }} Pasien</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<v-card-text class="pa-4">
|
||||
<v-row dense class="mb-4">
|
||||
<v-col cols="12" md="8">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Cari nama pasien, No. RM, atau alamat..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="search-field"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="statusFilter"
|
||||
:items="['Semua Status', ...statusOptions]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="filter-select"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="1">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="refreshData"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Data Table -->
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredPasien"
|
||||
:items-per-page="10"
|
||||
class="elevation-0 data-table"
|
||||
>
|
||||
<template #item.namaPasien="{ item }">
|
||||
<div class="patient-info">
|
||||
<div class="body-2 text-semibold">{{ item.namaPasien }}</div>
|
||||
<div class="caption-2 text-muted">{{ item.noRM }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
class="status-chip"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.aksi="{ item }">
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="btn-action mr-1"
|
||||
@click="openKlinikDialog(item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-hospital-box</v-icon>
|
||||
Klinik
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="flat"
|
||||
class="btn-action mr-1"
|
||||
@click="openKlinikRuangDialog(item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-door</v-icon>
|
||||
Ruang
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="#3b82f6"
|
||||
variant="flat"
|
||||
class="btn-action"
|
||||
@click="openPenunjangDialog(item)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-clipboard-pulse</v-icon>
|
||||
Penunjang
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Dialog Klinik -->
|
||||
<v-dialog v-model="showKlinikDialog" max-width="700px" persistent scrollable>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header">
|
||||
<span class="headline-4">Pilih Klinik</span>
|
||||
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikDialog">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<div v-if="selectedPatient" class="patient-card mb-4">
|
||||
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<div class="detail-item">
|
||||
<span class="caption-2 text-muted">Nama</span>
|
||||
<span class="body-2 text-semibold">{{ selectedPatient.namaPasien }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="detail-item">
|
||||
<span class="caption-2 text-muted">No. RM</span>
|
||||
<span class="body-2 text-semibold">{{ selectedPatient.noRM }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="klinikSearch"
|
||||
placeholder="Cari Klinik..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-row dense>
|
||||
<v-col
|
||||
v-for="klinik in filteredKliniks"
|
||||
:key="klinik.id"
|
||||
cols="6"
|
||||
sm="4"
|
||||
>
|
||||
<v-card
|
||||
class="option-card"
|
||||
elevation="0"
|
||||
@click="buatAntreanKlinik(klinik)"
|
||||
>
|
||||
<v-icon size="40" color="primary">mdi-hospital-box</v-icon>
|
||||
<span class="body-3 text-semibold text-center">{{ klinik.name }}</span>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog Klinik Ruang -->
|
||||
<v-dialog v-model="showKlinikRuangDialog" max-width="900px" persistent scrollable>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header dialog-header-warning">
|
||||
<span class="headline-4">Pilih Klinik Ruang</span>
|
||||
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikRuangDialog">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<div v-if="selectedPatient" class="patient-card mb-4">
|
||||
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<div class="detail-item">
|
||||
<span class="caption-2 text-muted">Nama</span>
|
||||
<span class="body-2 text-semibold">{{ selectedPatient.namaPasien }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="detail-item">
|
||||
<span class="caption-2 text-muted">No. RM</span>
|
||||
<span class="body-2 text-semibold">{{ selectedPatient.noRM }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="klinikRuangSearch"
|
||||
placeholder="Cari Klinik Ruang..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-expansion-panels class="expansion-panels">
|
||||
<v-expansion-panel
|
||||
v-for="klinikRuang in filteredKlinikRuang"
|
||||
:key="klinikRuang.id"
|
||||
class="expansion-panel"
|
||||
>
|
||||
<v-expansion-panel-title class="expansion-title">
|
||||
<div class="expansion-header">
|
||||
<v-chip size="small" color="primary" class="mr-2">
|
||||
{{ klinikRuang.kodeKlinik }}
|
||||
</v-chip>
|
||||
<span class="body-2 text-semibold">{{ klinikRuang.namaKlinik }}</span>
|
||||
</div>
|
||||
<template #actions>
|
||||
<v-chip size="x-small" variant="outlined">
|
||||
{{ klinikRuang.ruangList.length }} Ruang
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<div class="ruang-list">
|
||||
<div
|
||||
v-for="ruang in klinikRuang.ruangList"
|
||||
:key="ruang.nomorRuang"
|
||||
class="ruang-item"
|
||||
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
|
||||
>
|
||||
<v-icon color="warning" size="20">mdi-door</v-icon>
|
||||
<div class="ruang-info">
|
||||
<span class="body-3 text-semibold">Ruang {{ ruang.nomorRuang }} - {{ ruang.namaRuang }}</span>
|
||||
<span class="caption-2 text-muted">Screen: {{ ruang.nomorScreen }}</span>
|
||||
</div>
|
||||
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog Penunjang -->
|
||||
<v-dialog v-model="showPenunjangDialog" max-width="700px" persistent scrollable>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header dialog-header-secondary">
|
||||
<span class="headline-4">Pilih Penunjang</span>
|
||||
<v-btn icon variant="text" size="small" class="btn-close" @click="closePenunjangDialog">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<div v-if="selectedPatient" class="patient-card mb-4">
|
||||
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<div class="detail-item">
|
||||
<span class="caption-2 text-muted">Nama</span>
|
||||
<span class="body-2 text-semibold">{{ selectedPatient.namaPasien }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="detail-item">
|
||||
<span class="caption-2 text-muted">No. RM</span>
|
||||
<span class="body-2 text-semibold">{{ selectedPatient.noRM }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="penunjangSearch"
|
||||
placeholder="Cari Penunjang..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-row dense>
|
||||
<v-col
|
||||
v-for="penunjang in filteredPenunjangs"
|
||||
:key="penunjang.id"
|
||||
cols="6"
|
||||
sm="4"
|
||||
>
|
||||
<v-card
|
||||
class="option-card"
|
||||
elevation="0"
|
||||
@click="buatAntreanPenunjang(penunjang)"
|
||||
>
|
||||
<v-icon size="40" color="secondary">mdi-clipboard-pulse</v-icon>
|
||||
<span class="body-3 text-semibold text-center">{{ penunjang.name }}</span>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
|
||||
<span class="body-3">{{ snackbarText }}</span>
|
||||
<template #actions>
|
||||
<v-btn variant="text" size="small" @click="snackbar = false">Tutup</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const search = ref('');
|
||||
const statusFilter = ref('Semua Status');
|
||||
const snackbar = ref(false);
|
||||
const snackbarText = ref('');
|
||||
const snackbarColor = ref('success');
|
||||
|
||||
const currentDate = ref(
|
||||
new Date().toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
);
|
||||
|
||||
// Dialog states
|
||||
const showKlinikDialog = ref(false);
|
||||
const showKlinikRuangDialog = ref(false);
|
||||
const showPenunjangDialog = ref(false);
|
||||
const selectedPatient = ref(null);
|
||||
|
||||
// Search states
|
||||
const klinikSearch = ref('');
|
||||
const klinikRuangSearch = ref('');
|
||||
const penunjangSearch = ref('');
|
||||
|
||||
// Status options
|
||||
const statusOptions = ref(['Terdaftar', 'Belum Terdaftar', 'Proses']);
|
||||
|
||||
// Table headers
|
||||
const headers = ref([
|
||||
{ title: 'Nama Pasien', value: 'namaPasien', sortable: true },
|
||||
{ title: 'No. RM', value: 'noRM', sortable: true },
|
||||
{ title: 'Alamat', value: 'alamat', sortable: true },
|
||||
{ title: 'Status', value: 'status', sortable: true },
|
||||
{ title: 'Aksi', value: 'aksi', sortable: false },
|
||||
]);
|
||||
|
||||
// Mock data
|
||||
const pasienList = ref([
|
||||
{ id: 1, namaPasien: 'Ahmad Fauzi', noRM: '250811100163', alamat: 'Jl. Merdeka No. 123', status: 'Terdaftar' },
|
||||
{ id: 2, namaPasien: 'Siti Aminah', noRM: '250811100155', alamat: 'Jl. Sudirman No. 45', status: 'Terdaftar' },
|
||||
{ id: 3, namaPasien: 'Budi Santoso', noRM: '250811100200', alamat: 'Jl. Gatot Subroto No. 78', status: 'Belum Terdaftar' },
|
||||
{ id: 4, namaPasien: 'Dewi Lestari', noRM: '250811100210', alamat: 'Jl. Ahmad Yani No. 90', status: 'Terdaftar' },
|
||||
{ id: 5, namaPasien: 'Eko Prasetyo', noRM: '250811100220', alamat: 'Jl. Diponegoro No. 15', status: 'Proses' },
|
||||
]);
|
||||
|
||||
// Computed - Get data from Master Store
|
||||
const kliniksList = computed(() => {
|
||||
return masterStore.klinikList.map(k => ({
|
||||
id: k.id,
|
||||
name: k.nama,
|
||||
kode: k.kode
|
||||
}));
|
||||
});
|
||||
|
||||
const klinikRuangList = computed(() => {
|
||||
return masterStore.ruangData;
|
||||
});
|
||||
|
||||
const penunjangsList = computed(() => {
|
||||
return masterStore.getActivePenunjangList();
|
||||
});
|
||||
|
||||
const totalPasien = computed(() => pasienList.value.length);
|
||||
|
||||
const filteredPasien = computed(() => {
|
||||
let filtered = pasienList.value;
|
||||
|
||||
if (statusFilter.value && statusFilter.value !== 'Semua Status') {
|
||||
filtered = filtered.filter((p) => p.status === statusFilter.value);
|
||||
}
|
||||
|
||||
if (search.value) {
|
||||
const query = search.value.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(p) =>
|
||||
p.namaPasien.toLowerCase().includes(query) ||
|
||||
p.noRM.toLowerCase().includes(query) ||
|
||||
p.alamat.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const filteredKliniks = computed(() => {
|
||||
if (!klinikSearch.value) return kliniksList.value;
|
||||
return kliniksList.value.filter((k) =>
|
||||
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredKlinikRuang = computed(() => {
|
||||
if (!klinikRuangSearch.value) return klinikRuangList.value;
|
||||
return klinikRuangList.value.filter(
|
||||
(k) =>
|
||||
k.namaKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase()) ||
|
||||
k.kodeKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredPenunjangs = computed(() => {
|
||||
if (!penunjangSearch.value) return penunjangsList.value;
|
||||
return penunjangsList.value.filter((p) =>
|
||||
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const showSnackbarMessage = (text, color = 'success') => {
|
||||
snackbarText.value = text;
|
||||
snackbarColor.value = color;
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
Terdaftar: 'success',
|
||||
'Belum Terdaftar': 'warning',
|
||||
Proses: 'info',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
showSnackbarMessage('Data berhasil diperbarui', 'success');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const openKlinikDialog = (patient) => {
|
||||
selectedPatient.value = patient;
|
||||
showKlinikDialog.value = true;
|
||||
};
|
||||
|
||||
const closeKlinikDialog = () => {
|
||||
showKlinikDialog.value = false;
|
||||
selectedPatient.value = null;
|
||||
klinikSearch.value = '';
|
||||
};
|
||||
|
||||
const openKlinikRuangDialog = (patient) => {
|
||||
selectedPatient.value = patient;
|
||||
showKlinikRuangDialog.value = true;
|
||||
};
|
||||
|
||||
const closeKlinikRuangDialog = () => {
|
||||
showKlinikRuangDialog.value = false;
|
||||
selectedPatient.value = null;
|
||||
klinikRuangSearch.value = '';
|
||||
};
|
||||
|
||||
const openPenunjangDialog = (patient) => {
|
||||
selectedPatient.value = patient;
|
||||
showPenunjangDialog.value = true;
|
||||
};
|
||||
|
||||
const closePenunjangDialog = () => {
|
||||
showPenunjangDialog.value = false;
|
||||
selectedPatient.value = null;
|
||||
penunjangSearch.value = '';
|
||||
};
|
||||
|
||||
const buatAntreanKlinik = (klinik) => {
|
||||
showSnackbarMessage(
|
||||
`Antrean klinik ${klinik.name} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`,
|
||||
'success'
|
||||
);
|
||||
closeKlinikDialog();
|
||||
};
|
||||
|
||||
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
|
||||
showSnackbarMessage(
|
||||
`Antrean ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`,
|
||||
'success'
|
||||
);
|
||||
closeKlinikRuangDialog();
|
||||
};
|
||||
|
||||
const buatAntreanPenunjang = (penunjang) => {
|
||||
showSnackbarMessage(
|
||||
`Antrean penunjang ${penunjang.name} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`,
|
||||
'success'
|
||||
);
|
||||
closePenunjangDialog();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Colors from Design System
|
||||
$neutral-900: #212121;
|
||||
$neutral-800: #4D4D4D;
|
||||
$neutral-700: #717171;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-100: #FFFFFF;
|
||||
|
||||
$primary-700: #FF9B1B;
|
||||
$primary-600: #FFA532;
|
||||
|
||||
$secondary-700: #0053AD;
|
||||
$secondary-600: #0671E0;
|
||||
|
||||
$warning-600: #FFB95F;
|
||||
|
||||
$success-600: #009262;
|
||||
$success-300: #84DFC1;
|
||||
|
||||
// Font
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
* {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PAGE HEADER
|
||||
// ============================================
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: $neutral-100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
line-height: 44px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
color: $neutral-100;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $neutral-100;
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $primary-600 !important;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FILTERS & SEARCH
|
||||
// ============================================
|
||||
.search-field,
|
||||
.filter-select {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATA TABLE
|
||||
// ============================================
|
||||
.data-table {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: $neutral-700;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
text-transform: none;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DIALOG
|
||||
// ============================================
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
color: $neutral-100;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-header-warning {
|
||||
background: linear-gradient(135deg, $warning-600 0%, $primary-700 100%);
|
||||
}
|
||||
|
||||
.dialog-header-secondary {
|
||||
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||
}
|
||||
|
||||
.headline-4 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headline-5 {
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
color: $neutral-900;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px !important;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PATIENT CARD
|
||||
// ============================================
|
||||
.patient-card {
|
||||
background: $neutral-100;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid $neutral-400;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.caption-2 {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
.body-2 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
.body-3 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
.text-semibold {
|
||||
font-weight: $font-weight-semibold !important;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OPTION CARDS
|
||||
// ============================================
|
||||
.option-card {
|
||||
cursor: pointer;
|
||||
border: 2px solid $neutral-400;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
background: $neutral-100;
|
||||
|
||||
&:hover {
|
||||
border-color: $secondary-600;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(6, 113, 224, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EXPANSION PANELS
|
||||
// ============================================
|
||||
.expansion-panels {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.expansion-panel {
|
||||
background: $neutral-100;
|
||||
border: 1px solid $neutral-400;
|
||||
border-radius: 12px !important;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.expansion-title {
|
||||
background: $neutral-100;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.expansion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ruang-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.ruang-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: $neutral-300;
|
||||
border: 1px solid $neutral-400;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $warning-600;
|
||||
background: lighten($warning-600, 35%);
|
||||
}
|
||||
}
|
||||
|
||||
.ruang-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RESPONSIVE
|
||||
// ============================================
|
||||
@media (max-width: 960px) {
|
||||
.page-header {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.btn-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<v-app class="bg-modern">
|
||||
<v-main class="no-overflow">
|
||||
<v-container fluid class="no-scroll-container pa-2 pa-md-4">
|
||||
<v-row align="start" justify="center" class="fill-height">
|
||||
<v-col cols="12" sm="11" md="10" lg="8" xl="7" class="d-flex flex-column">
|
||||
<!-- Main Card dengan Glassmorphism -->
|
||||
<v-card class="main-card" elevation="0">
|
||||
|
||||
<!-- Header Minimalis -->
|
||||
<div class="header-modern">
|
||||
<div class="header-content">
|
||||
<div class="icon-circle">
|
||||
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="title-modern">Check-in Pasien</h1>
|
||||
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Minimalis -->
|
||||
<v-tabs
|
||||
v-model="tab"
|
||||
align-tabs="center"
|
||||
class="tabs-modern mt-3"
|
||||
bg-color="transparent"
|
||||
slider-color="#FB8C00"
|
||||
height="40"
|
||||
>
|
||||
<v-tab value="scan" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||
<span>Scan QR</span>
|
||||
</v-tab>
|
||||
<v-tab value="manual" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
|
||||
<span>Manual</span>
|
||||
</v-tab>
|
||||
<v-tab value="generate" class="tab-modern">
|
||||
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
|
||||
<span>Generate QR</span>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</div>
|
||||
|
||||
<v-card-text class="content-modern pa-4 pa-md-6">
|
||||
<v-window v-model="tab">
|
||||
<!-- Tab Scan QR -->
|
||||
<v-window-item value="scan">
|
||||
<QRScanTab
|
||||
:is-scanning="isScanning"
|
||||
:has-camera="hasCamera"
|
||||
:camera-checking="cameraChecking"
|
||||
:camera-ready="cameraReady"
|
||||
:primary-color="primaryColor"
|
||||
@start-scanning="startScanning"
|
||||
@stop-scanning="stopScanning"
|
||||
@test-camera="testCamera"
|
||||
@open-history="openHistoryDialog"
|
||||
@open-qr-history="openQRHistoryDialog"
|
||||
/>
|
||||
</v-window-item>
|
||||
|
||||
<!-- Tab Manual -->
|
||||
<v-window-item value="manual">
|
||||
<ManualInputTab
|
||||
v-model="manualInput"
|
||||
:primary-color="primaryColor"
|
||||
:secondary-color="secondaryColor"
|
||||
@submit="checkInManual"
|
||||
@open-history="openHistoryDialog"
|
||||
ref="manualInputTabRef"
|
||||
/>
|
||||
</v-window-item>
|
||||
|
||||
<!-- Tab Generate QR -->
|
||||
<v-window-item value="generate">
|
||||
<div class="tab-content">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header mb-3">
|
||||
<div class="status-icon-wrapper">
|
||||
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 class="status-title">Generate QR Code untuk Testing</h3>
|
||||
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Preset Buttons -->
|
||||
<div class="mb-3">
|
||||
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="small"
|
||||
@click="generateQuickQR(generateRandomPatientId(), 'ALLOWED')"
|
||||
class="text-none"
|
||||
block
|
||||
>
|
||||
<v-icon start size="16">mdi-check-circle</v-icon>
|
||||
Test ALLOWED
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="generateQuickQR(generateRandomPatientId(), 'NOT_ALLOWED')"
|
||||
class="text-none"
|
||||
block
|
||||
>
|
||||
<v-icon start size="16">mdi-clock-alert</v-icon>
|
||||
Test NOT_ALLOWED
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Form Generate -->
|
||||
<v-form @submit.prevent="generateQRCode">
|
||||
<v-text-field
|
||||
v-model="generatePatientId"
|
||||
label="ID Pasien"
|
||||
placeholder="Contoh: P-123456"
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-3"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details="auto"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="generateRandomId()"
|
||||
title="Generate Random ID"
|
||||
>
|
||||
<v-icon size="20">mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-select
|
||||
v-model="generateStatus"
|
||||
label="Status Check-in"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-shield-check"
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
class="input-modern mb-4"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
></v-select>
|
||||
|
||||
<div class="d-flex justify-center">
|
||||
<v-btn
|
||||
class="btn-primary-modern btn-centered"
|
||||
size="large"
|
||||
type="submit"
|
||||
elevation="0"
|
||||
:disabled="!generatePatientId"
|
||||
>
|
||||
<v-icon start size="20">mdi-qrcode-plus</v-icon>
|
||||
Generate QR Code
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
|
||||
<!-- QR Code Display -->
|
||||
<div v-if="generatedQRData" class="qr-display mt-6">
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<div class="text-center">
|
||||
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
|
||||
<div id="qrcode" class="qr-code-container mb-4"></div>
|
||||
|
||||
<v-chip :color="generateStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
|
||||
<v-icon start>{{ generateStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
|
||||
{{ generateStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
|
||||
</v-chip>
|
||||
|
||||
<p class="text-body-2 text-grey mb-4">
|
||||
Data: {{ generatedQRData }}
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="downloadQR"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-download</v-icon>
|
||||
Download
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="copyQRToClipboard"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-content-copy</v-icon>
|
||||
Copy
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
:color="primaryColor"
|
||||
block
|
||||
size="small"
|
||||
@click="shareQR"
|
||||
class="text-none"
|
||||
>
|
||||
<v-icon start size="18">mdi-share-variant</v-icon>
|
||||
Share
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Instructions -->
|
||||
<v-alert
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mt-4 text-body-2"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</template>
|
||||
<strong>Cara menggunakan untuk Testing:</strong>
|
||||
<ol class="ml-4 mt-2">
|
||||
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
|
||||
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
|
||||
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
|
||||
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
|
||||
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
|
||||
</ol>
|
||||
</v-alert>
|
||||
</div>
|
||||
</div>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Stats Footer Minimalis -->
|
||||
<div class="stats-footer-modern mt-3">
|
||||
<v-row dense>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ todayCount }}</div>
|
||||
<div class="stat-label">Hari Ini</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon color="success" size="20">mdi-check-circle</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ completedCount }}</div>
|
||||
<div class="stat-label">Selesai</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="stat-card-modern">
|
||||
<div class="stat-icon-modern">
|
||||
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
|
||||
</div>
|
||||
<div class="stat-value">{{ pendingCount }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<!-- Check-in Result Dialog -->
|
||||
<CheckInDialog
|
||||
v-model="infoDialog"
|
||||
:last-check-in-result="lastCheckInResult"
|
||||
:info-message="infoMessage"
|
||||
:info-action="infoAction"
|
||||
:scanned-data="scannedData"
|
||||
@close="handleInfoAction"
|
||||
/>
|
||||
|
||||
<!-- Enhanced Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbarShow"
|
||||
:color="snackbar.color"
|
||||
:timeout="snackbar.timeout"
|
||||
location="bottom right"
|
||||
rounded="pill"
|
||||
elevation="24"
|
||||
class="custom-snackbar"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar :color="snackbar.color" size="32" class="mr-3">
|
||||
<v-icon size="20" color="white">{{ snackbar.icon }}</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="font-weight-bold">{{ snackbar.title }}</div>
|
||||
<div class="text-body-2">{{ snackbar.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-snackbar>
|
||||
|
||||
<!-- History Dialog -->
|
||||
<HistoryDialog
|
||||
v-model="historyDialog"
|
||||
:search="historySearch"
|
||||
:date-filter="historyDateFilter"
|
||||
:status-filter="historyStatusFilter"
|
||||
:filtered-history="filteredHistory"
|
||||
:history="checkInHistory"
|
||||
:status-options="historyStatusOptions"
|
||||
:primary-color="primaryColor"
|
||||
:get-status-color="getStatusColor"
|
||||
:get-status-icon="getStatusIcon"
|
||||
:get-status-text="getStatusText"
|
||||
:get-status-class="getStatusClass"
|
||||
:format-date-time="formatDateTime"
|
||||
:format-date="formatDate"
|
||||
@update:search="historySearch = $event"
|
||||
@update:date-filter="historyDateFilter = $event"
|
||||
@update:status-filter="historyStatusFilter = $event"
|
||||
@delete-item="handleDeleteHistoryItem"
|
||||
@clear="handleClearHistory"
|
||||
/>
|
||||
|
||||
<!-- QR History Dialog -->
|
||||
<QRHistoryDialog
|
||||
v-model="qrHistoryDialog"
|
||||
:search="historySearch"
|
||||
:date-filter="historyDateFilter"
|
||||
:filtered-history="filteredQRHistory"
|
||||
:history="scannedQRHistory"
|
||||
:primary-color="primaryColor"
|
||||
@update:search="historySearch = $event"
|
||||
@update:date-filter="historyDateFilter = $event"
|
||||
@use-data="useQRData"
|
||||
@delete-item="handleDeleteQRHistoryItem"
|
||||
@clear="handleClearQRHistory"
|
||||
/>
|
||||
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import type { TabValue, InfoAction, CheckInHistoryItem } from '~/types/checkin';
|
||||
import { PRIMARY_COLOR, SECONDARY_COLOR, QR_STATUS_OPTIONS } from '~/constants/checkin';
|
||||
import { useSnackbar } from '~/composables/useSnackbar';
|
||||
import { useCheckInHistory } from '~/composables/useCheckInHistory';
|
||||
import { useQRScanner } from '~/composables/useQRScanner';
|
||||
import { useCheckIn } from '~/composables/useCheckIn';
|
||||
import { useQRGenerator } from '~/composables/useQRGenerator';
|
||||
import CheckInDialog from '~/components/checkin/CheckInDialog.vue';
|
||||
import HistoryDialog from '~/components/checkin/HistoryDialog.vue';
|
||||
import QRHistoryDialog from '~/components/checkin/QRHistoryDialog.vue';
|
||||
import ManualInputTab from '~/components/checkin/ManualInputTab.vue';
|
||||
import QRScanTab from '~/components/checkin/QRScanTab.vue';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth'],
|
||||
layout: false,
|
||||
})
|
||||
|
||||
// TypeScript declaration for QRCode
|
||||
declare global {
|
||||
interface Window {
|
||||
QRCode: typeof import('qrcode').default;
|
||||
}
|
||||
}
|
||||
|
||||
// --- DESAIN & TEMA ---
|
||||
const primaryColor = ref(PRIMARY_COLOR);
|
||||
const secondaryColor = ref(SECONDARY_COLOR);
|
||||
|
||||
// --- LOGIKA ---
|
||||
const tab = ref<TabValue>('scan');
|
||||
const infoDialog = ref(false);
|
||||
const infoMessage = ref('');
|
||||
const infoAction = ref<InfoAction>('kembali');
|
||||
const scannedData = ref<string | null>(null);
|
||||
const manualInput = ref('');
|
||||
const manualInputTabRef = ref<InstanceType<typeof ManualInputTab> | null>(null);
|
||||
// lastCheckInResult sudah dipindahkan ke useCheckIn composable
|
||||
|
||||
// Timer untuk auto-close dialog
|
||||
let dialogAutoCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Fungsi untuk auto-close dialog setelah 15 detik
|
||||
const autoCloseDialog = () => {
|
||||
// Clear timer sebelumnya jika ada
|
||||
if (dialogAutoCloseTimer) {
|
||||
clearTimeout(dialogAutoCloseTimer);
|
||||
}
|
||||
|
||||
// Set timer untuk menutup dialog setelah 15 detik
|
||||
dialogAutoCloseTimer = setTimeout(() => {
|
||||
if (infoDialog.value) {
|
||||
infoDialog.value = false;
|
||||
dialogAutoCloseTimer = null;
|
||||
}
|
||||
}, 15000); // 15 detik
|
||||
};
|
||||
|
||||
// Snackbar composable (harus didefinisikan sebelum useQRScanner)
|
||||
const { snackbar, snackbarShow, showSnackbar } = useSnackbar();
|
||||
|
||||
// History composable
|
||||
const {
|
||||
checkInHistory,
|
||||
scannedQRHistory,
|
||||
historySearch,
|
||||
historyDateFilter,
|
||||
historyStatusFilter,
|
||||
filteredHistory,
|
||||
filteredQRHistory,
|
||||
loadHistory,
|
||||
saveToHistory,
|
||||
deleteHistoryItem,
|
||||
clearHistory,
|
||||
loadScannedQRHistory,
|
||||
saveScannedQRData,
|
||||
clearQRHistory,
|
||||
deleteQRHistoryItem,
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
getStatusText,
|
||||
getStatusClass,
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
historyStatusOptions,
|
||||
} = useCheckInHistory();
|
||||
|
||||
// Computed untuk stats footer
|
||||
const todayCount = computed(() => {
|
||||
const today = new Date().toDateString()
|
||||
return checkInHistory.value.filter((item: CheckInHistoryItem) => {
|
||||
const itemDate = new Date(item.checkInDate).toDateString()
|
||||
return itemDate === today
|
||||
}).length
|
||||
})
|
||||
|
||||
const completedCount = computed(() => {
|
||||
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
|
||||
item.status === 'success' || item.status === 'ALLOWED'
|
||||
).length
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => {
|
||||
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
|
||||
item.status === 'pending' || item.status === 'NOT_ALLOWED'
|
||||
).length
|
||||
})
|
||||
|
||||
// Check-in composable (didefinisikan dulu, saveSuccessfulScan akan di-set nanti)
|
||||
let saveSuccessfulScanRef: ((qrData: string) => void) | null = null
|
||||
|
||||
const {
|
||||
lastCheckInResult,
|
||||
processQRCode,
|
||||
checkInManual: checkInManualComposable,
|
||||
} = useCheckIn({
|
||||
showSnackbar,
|
||||
saveToHistory,
|
||||
saveSuccessfulScan: (qrData: string) => {
|
||||
// Delegate ke saveSuccessfulScan dari useQRScanner
|
||||
if (saveSuccessfulScanRef) {
|
||||
saveSuccessfulScanRef(qrData)
|
||||
}
|
||||
},
|
||||
onCheckInSuccess: (result) => {
|
||||
infoMessage.value = result.message
|
||||
infoAction.value = result.action
|
||||
infoDialog.value = true
|
||||
},
|
||||
autoCloseDialog,
|
||||
})
|
||||
|
||||
// QR Scanner composable (setelah useCheckIn agar bisa akses processQRCode)
|
||||
const {
|
||||
isScanning,
|
||||
hasCamera,
|
||||
cameraChecking,
|
||||
cameraReady,
|
||||
checkCameraAvailability,
|
||||
testCamera,
|
||||
startScanning,
|
||||
stopScanning,
|
||||
saveSuccessfulScan,
|
||||
} = useQRScanner(
|
||||
(decodedText: string) => {
|
||||
// Callback ketika QR berhasil di-scan
|
||||
scannedData.value = decodedText
|
||||
processQRCode(decodedText)
|
||||
// Simpan ke history scanned QR
|
||||
saveScannedQRData(decodedText)
|
||||
},
|
||||
showSnackbar
|
||||
)
|
||||
|
||||
// Set saveSuccessfulScanRef setelah useQRScanner didefinisikan
|
||||
saveSuccessfulScanRef = saveSuccessfulScan
|
||||
|
||||
// History Dialog
|
||||
const historyDialog = ref(false);
|
||||
const qrHistoryDialog = ref(false);
|
||||
|
||||
// Generate random patient ID function (untuk digunakan di composable dan quick test buttons)
|
||||
const generateRandomPatientId = () => {
|
||||
// Generate random 6-digit number
|
||||
const randomNum = Math.floor(100000 + Math.random() * 900000);
|
||||
return `P-${randomNum}`;
|
||||
};
|
||||
|
||||
// QR Generator composable
|
||||
const {
|
||||
generatePatientId,
|
||||
generateStatus,
|
||||
generatedQRData,
|
||||
generateRandomId,
|
||||
generateQuickQR,
|
||||
generateQRCode,
|
||||
downloadQR,
|
||||
copyQRToClipboard,
|
||||
shareQR,
|
||||
} = useQRGenerator({
|
||||
showSnackbar,
|
||||
generateRandomPatientId,
|
||||
});
|
||||
|
||||
const statusOptions = [...QR_STATUS_OPTIONS] as Array<{ title: string; value: string }>;
|
||||
|
||||
|
||||
// Watch tab changes untuk stop scanner saat pindah tab
|
||||
watch(tab, (newTab: TabValue) => {
|
||||
if (newTab !== 'scan' && isScanning.value) {
|
||||
stopScanning();
|
||||
}
|
||||
// Check camera when switching to scan tab
|
||||
if (newTab === 'scan' && !hasCamera.value && !cameraChecking.value) {
|
||||
checkCameraAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
// Check camera on mount
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
||||
if (navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') {
|
||||
checkCameraAvailability();
|
||||
} else {
|
||||
console.warn('MediaDevices API not available. Make sure you are using HTTPS or localhost.');
|
||||
hasCamera.value = false;
|
||||
cameraChecking.value = false;
|
||||
showSnackbar('Warning', 'MediaDevices API tidak tersedia. Pastikan menggunakan HTTPS atau localhost.', 'warning', 'mdi-alert');
|
||||
}
|
||||
} else {
|
||||
hasCamera.value = false;
|
||||
cameraChecking.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup saat component unmount
|
||||
onUnmounted(() => {
|
||||
if (isScanning.value) {
|
||||
stopScanning();
|
||||
}
|
||||
// Clear dialog auto-close timer
|
||||
if (dialogAutoCloseTimer) {
|
||||
clearTimeout(dialogAutoCloseTimer);
|
||||
dialogAutoCloseTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// onDetect sudah dipindahkan ke useCheckIn composable sebagai processQRCode
|
||||
|
||||
const handleInfoAction = async () => {
|
||||
// Clear timer jika dialog ditutup secara manual
|
||||
if (dialogAutoCloseTimer) {
|
||||
clearTimeout(dialogAutoCloseTimer);
|
||||
dialogAutoCloseTimer = null;
|
||||
}
|
||||
|
||||
infoDialog.value = false;
|
||||
|
||||
// Scanner tetap berjalan setelah dialog ditutup
|
||||
// Ini memungkinkan user untuk segera memproses QR code berikutnya
|
||||
// tanpa perlu memulai scanner lagi
|
||||
};
|
||||
|
||||
// performCheckIn sudah dipindahkan ke useCheckIn composable
|
||||
|
||||
const checkInManual = async () => {
|
||||
try {
|
||||
if (!manualInput.value || !manualInput.value.trim()) {
|
||||
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
const patientId = manualInput.value.trim();
|
||||
|
||||
// Validate form if available
|
||||
if (manualInputTabRef.value?.form) {
|
||||
try {
|
||||
const isValid = manualInputTabRef.value.form.validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// If validate fails, continue anyway
|
||||
console.warn('Form validation error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await checkInManualComposable(
|
||||
patientId,
|
||||
() => {
|
||||
// onSuccess callback
|
||||
try {
|
||||
manualInput.value = '';
|
||||
if (manualInputTabRef.value?.form) {
|
||||
try {
|
||||
manualInputTabRef.value.form.reset();
|
||||
} catch (e) {
|
||||
// Ignore reset errors
|
||||
console.warn('Form reset error:', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error in success callback:', e);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// onError callback - tidak perlu melakukan apa-apa karena error sudah ditangani di composable
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in checkInManual:', error);
|
||||
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert');
|
||||
}
|
||||
};
|
||||
|
||||
// generateQRCode, generateQuickQR, downloadQR, copyQRToClipboard, shareQR sudah dipindahkan ke useQRGenerator composable
|
||||
|
||||
// History Functions - semua sudah dipindahkan ke useCheckInHistory composable
|
||||
|
||||
const openHistoryDialog = () => {
|
||||
loadHistory();
|
||||
historyDialog.value = true;
|
||||
};
|
||||
|
||||
const openQRHistoryDialog = () => {
|
||||
loadScannedQRHistory();
|
||||
qrHistoryDialog.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteHistoryItem = (index: number) => {
|
||||
deleteHistoryItem(index);
|
||||
showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check');
|
||||
};
|
||||
|
||||
const handleClearHistory = () => {
|
||||
clearHistory();
|
||||
showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check');
|
||||
};
|
||||
|
||||
const handleDeleteQRHistoryItem = (index: number) => {
|
||||
deleteQRHistoryItem(index);
|
||||
showSnackbar('Berhasil', 'Riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
|
||||
};
|
||||
|
||||
const handleClearQRHistory = () => {
|
||||
clearQRHistory();
|
||||
showSnackbar('Berhasil', 'Semua riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
|
||||
};
|
||||
|
||||
const useQRData = (qrData: string) => {
|
||||
qrHistoryDialog.value = false;
|
||||
scannedData.value = qrData;
|
||||
processQRCode(qrData);
|
||||
};
|
||||
|
||||
// filteredHistory, filteredQRHistory, dan helper functions sudah dipindahkan ke useCheckInHistory composable
|
||||
|
||||
// loadSuccessfulScans sudah dipanggil di dalam useQRScanner composable saat initialization
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~/assets/scss/checkin/variables';
|
||||
@import '~/assets/scss/checkin/components';
|
||||
@import '~/assets/scss/checkin/dialogs';
|
||||
</style>
|
||||
@@ -0,0 +1,744 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-8 dashboard-bg">
|
||||
|
||||
<div class="d-flex justify-space-between align-center mb-10">
|
||||
<div>
|
||||
<h1 class="dashboard-title">
|
||||
Antrean Dashboard
|
||||
</h1>
|
||||
<p v-if="user" class="dashboard-subtitle">
|
||||
Selamat Datang,
|
||||
<span class="user-name">
|
||||
{{ user.name || user.preferred_username }}
|
||||
</span>! 🍊
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
color="primary-600"
|
||||
variant="flat"
|
||||
class="export-btn mr-4"
|
||||
>
|
||||
<v-icon start icon="mdi-download-box-outline"></v-icon>
|
||||
Export Data ({{ exportType }})
|
||||
<v-icon end>mdi-menu-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="(item, index) in exportOptions"
|
||||
:key="index"
|
||||
@click="handleExport(item.type)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
<v-icon start :icon="item.icon"></v-icon>
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-chip color="white" variant="flat" class="date-chip">
|
||||
<v-icon start icon="mdi-calendar-check-outline" color="primary-600"></v-icon>
|
||||
{{ currentDate }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-10">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card class="stat-card stat-card-primary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="48" color="primary-600" class="mr-4">mdi-tablet-dashboard</v-icon>
|
||||
<div>
|
||||
<div class="stat-value">150</div>
|
||||
<div class="stat-label">Total Antrean Online</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card class="stat-card stat-card-secondary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="48" color="secondary-600" class="mr-4">mdi-account-hard-hat</v-icon>
|
||||
<div>
|
||||
<div class="stat-value">100</div>
|
||||
<div class="stat-label">Total Antrean MJKN</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card class="stat-card stat-card-primary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="48" color="primary-600" class="mr-4">mdi-account-group</v-icon>
|
||||
<div>
|
||||
<div class="stat-value">500</div>
|
||||
<div class="stat-label">Total Kunjungan</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card class="stat-card stat-card-secondary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="48" color="secondary-600" class="mr-4">mdi-timer-sand</v-icon>
|
||||
<div>
|
||||
<div class="stat-value">7.5s</div>
|
||||
<div class="stat-label">Avg. Check-in Time</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="chart-card">
|
||||
<v-card-title class="chart-header">
|
||||
<span class="chart-title">Registration Trend</span>
|
||||
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="primary-600" class="period-toggle">
|
||||
<v-btn size="small" value="day">Day</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-0">
|
||||
<Bar
|
||||
:data="queueChartData"
|
||||
:options="queueChartOptions"
|
||||
style="height: 350px"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="chart-card">
|
||||
<v-card-title class="chart-header">
|
||||
<span class="chart-title">Total Registrasi Perbulan</span>
|
||||
<div>
|
||||
<v-chip
|
||||
:color="activeYear === '2024' ? 'primary-600' : 'neutral-300'"
|
||||
:text-color="activeYear === '2024' ? 'white' : 'neutral-700'"
|
||||
variant="flat"
|
||||
class="year-chip mr-2"
|
||||
@click="changeYear('2024')"
|
||||
>
|
||||
2024
|
||||
</v-chip>
|
||||
<v-chip
|
||||
:color="activeYear === '2025' ? 'primary-600' : 'neutral-300'"
|
||||
:text-color="activeYear === '2025' ? 'white' : 'neutral-700'"
|
||||
variant="flat"
|
||||
class="year-chip"
|
||||
@click="changeYear('2025')"
|
||||
>
|
||||
2025
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-0">
|
||||
<Bar
|
||||
:data="barData"
|
||||
:options="barOptions"
|
||||
style="height: 350px"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="chart-card">
|
||||
<v-card-title class="chart-header">
|
||||
<span class="chart-title">Realtime Check-in Velocity</span>
|
||||
<v-chip size="small" color="success-600" class="live-chip">
|
||||
<v-icon start size="small">mdi-circle</v-icon>
|
||||
Live
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-0">
|
||||
<Line
|
||||
:data="realtimeLineData"
|
||||
:options="lineOptions"
|
||||
style="height: 350px"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="chart-card">
|
||||
<v-card-title class="chart-header">
|
||||
<span class="chart-title">Registrant Type Breakdown</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="d-flex justify-center align-center pa-0" style="height: 350px;">
|
||||
<Pie
|
||||
:data="pieData"
|
||||
:options="pieOptions"
|
||||
style="max-height: 100%; max-width: 100%;"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-overlay v-model="isLoading" contained class="align-center justify-center">
|
||||
<v-progress-circular indeterminate size="64" color="primary-600"></v-progress-circular>
|
||||
<p class="loading-text">Loading dashboard...</p>
|
||||
</v-overlay>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue';
|
||||
import { Bar, Pie, Line } from 'vue-chartjs';
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
import dayjs from 'dayjs';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import 'dayjs/locale/id';
|
||||
|
||||
dayjs.extend(weekday);
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.locale('id');
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
} from 'chart.js';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
|
||||
|
||||
const user = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const { checkAuth } = useAuth();
|
||||
|
||||
const exportType = ref('JSON');
|
||||
const exportOptions = ref([
|
||||
{ title: 'JSON (.json)', type: 'json', icon: 'mdi-code-json' },
|
||||
{ title: 'CSV (.csv)', type: 'csv', icon: 'mdi-file-delimited' },
|
||||
{ title: 'Excel (.xlsx) - Server Only', type: 'excel', icon: 'mdi-file-excel' },
|
||||
{ title: 'PDF (.pdf) - Placeholder', type: 'pdf', icon: 'mdi-file-pdf' },
|
||||
]);
|
||||
|
||||
const currentDate = ref('');
|
||||
const activeYear = ref('2025');
|
||||
const queueTimePeriod = ref('day');
|
||||
|
||||
const mockQueueData = ref([
|
||||
{ date: '2025-10-13', registrants: 300, attendees: 250 },
|
||||
{ date: '2025-10-14', registrants: 450, attendees: 400 },
|
||||
{ date: '2025-10-15', registrants: 500, attendees: 420 },
|
||||
{ date: '2025-10-16', registrants: 400, attendees: 350 },
|
||||
{ date: '2025-10-17', registrants: 550, attendees: 500 },
|
||||
{ date: '2025-10-18', registrants: 435, attendees: 380 },
|
||||
]);
|
||||
|
||||
// --- SIMPLIFIED REALTIME CHART (NO AUTO-UPDATE) ---
|
||||
const maxDataPoints = 10;
|
||||
|
||||
const realtimeLineData = ref({
|
||||
labels: Array.from({ length: maxDataPoints }, (_, i) =>
|
||||
dayjs().subtract((maxDataPoints - 1 - i) * 30, 'second').format('HH:mm:ss')
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Check-ins/min',
|
||||
backgroundColor: 'rgba(6, 99, 199, 0.1)',
|
||||
borderColor: '#0671E0',
|
||||
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: '#0663C7',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lineOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 0 },
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
title: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: 80,
|
||||
title: { display: true, text: 'Check-ins per Minute' },
|
||||
grid: { color: 'rgba(0, 0, 0, 0.05)' }
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Time (HH:MM:SS)' },
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
// --- END SIMPLIFIED REALTIME CHART ---
|
||||
|
||||
// Single onMounted
|
||||
onMounted(async () => {
|
||||
console.log('📊 Dashboard mounted');
|
||||
try {
|
||||
const sessionUser = await checkAuth();
|
||||
if (sessionUser) {
|
||||
user.value = sessionUser;
|
||||
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
|
||||
console.log('✅ Dashboard loaded successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Auth check error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean onUnmounted
|
||||
onUnmounted(() => {
|
||||
console.log('🧹 Dashboard unmounting');
|
||||
});
|
||||
|
||||
const changeYear = (year) => {
|
||||
activeYear.value = year;
|
||||
};
|
||||
|
||||
const getKpiData = () => {
|
||||
return [
|
||||
{ name: 'Antrean Online', value: 150 },
|
||||
{ name: 'Antrean MJKN', value: 100 },
|
||||
{ name: 'Total Kunjungan', value: 500 },
|
||||
{ name: 'Avg. Check-in Time', value: '7.5s' },
|
||||
];
|
||||
};
|
||||
|
||||
const convertJsonToCsv = (jsonData, fields) => {
|
||||
if (!jsonData.length) return '';
|
||||
|
||||
const header = fields.join(',');
|
||||
|
||||
const csv = jsonData.map(row =>
|
||||
fields.map(fieldName => {
|
||||
let value = row[fieldName];
|
||||
if (typeof value === 'string') {
|
||||
value = `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}).join(',')
|
||||
).join('\n');
|
||||
|
||||
return header + '\n' + csv;
|
||||
};
|
||||
|
||||
const downloadFile = (data, filename, mimeType) => {
|
||||
const dataStr = `data:${mimeType};charset=utf-8,` + encodeURIComponent(data);
|
||||
const downloadAnchorNode = document.createElement('a');
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", filename);
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}
|
||||
|
||||
const handleExport = (type) => {
|
||||
exportType.value = type.toUpperCase();
|
||||
const filenamePrefix = "antrean_data_" + dayjs().format('YYYYMMDD');
|
||||
|
||||
const dataToExport = mockQueueData.value;
|
||||
|
||||
if (type === 'json') {
|
||||
downloadFile(JSON.stringify(dataToExport, null, 2), `${filenamePrefix}.json`, 'application/json');
|
||||
alert("Data Antrean berhasil diexport sebagai JSON! ✅");
|
||||
|
||||
} else if (type === 'csv') {
|
||||
const fields = ['date', 'registrants', 'attendees'];
|
||||
const csvContent = convertJsonToCsv(dataToExport, fields);
|
||||
|
||||
const kpiData = getKpiData().map(kpi => `# ${kpi.name}: ${kpi.value}`).join('\n');
|
||||
const finalCsvContent = kpiData + '\n' + csvContent;
|
||||
|
||||
downloadFile(finalCsvContent, `${filenamePrefix}.csv`, 'text/csv');
|
||||
alert("Data Antrean berhasil diexport sebagai CSV! 📊");
|
||||
|
||||
} else if (type === 'excel') {
|
||||
alert("Excel (.xlsx) export requires a dedicated server endpoint. Simulating download... ⚠️");
|
||||
|
||||
} else if (type === 'pdf') {
|
||||
alert("PDF (.pdf) export requires client-side libraries (like jsPDF) or a server service. Simulating download... 📝");
|
||||
}
|
||||
};
|
||||
|
||||
const processQueueData = (data, period) => {
|
||||
const grouped = {};
|
||||
const sortedDates = data.map(item => ({
|
||||
...item,
|
||||
date: dayjs(item.date)
|
||||
})).sort((a, b) => a.date.valueOf() - b.date.valueOf());
|
||||
|
||||
sortedDates.forEach(item => {
|
||||
let key;
|
||||
let label;
|
||||
if (period === 'day') {
|
||||
key = item.date.format('YYYY-MM-DD');
|
||||
label = item.date.format('ddd, DD/MM');
|
||||
} else if (period === 'week') {
|
||||
key = item.date.format('YYYY-WW');
|
||||
label = `Wk ${item.date.week()}`;
|
||||
} else if (period === 'month') {
|
||||
key = item.date.format('YYYY-MM');
|
||||
label = item.date.format('MMM YYYY');
|
||||
}
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = { label: label, registrants: 0, attendees: 0 };
|
||||
}
|
||||
grouped[key].registrants += item.registrants;
|
||||
grouped[key].attendees += item.attendees;
|
||||
});
|
||||
|
||||
const finalLabels = Object.values(grouped).map(g => g.label);
|
||||
const finalRegistrants = Object.values(grouped).map(g => g.registrants);
|
||||
const finalAttendees = Object.values(grouped).map(g => g.attendees);
|
||||
|
||||
return {
|
||||
labels: finalLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: `Registrants`,
|
||||
backgroundColor: '#FFB95F',
|
||||
data: finalRegistrants,
|
||||
yAxisID: 'y1',
|
||||
type: 'bar',
|
||||
borderRadius: 6,
|
||||
},
|
||||
{
|
||||
label: `Attendees`,
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: '#0671E0',
|
||||
data: finalAttendees,
|
||||
yAxisID: 'y1',
|
||||
type: 'line',
|
||||
pointRadius: 5,
|
||||
pointBackgroundColor: '#0663C7',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const queueChartData = computed(() => {
|
||||
if (!mockQueueData.value.length) return { labels: [], datasets: [] };
|
||||
return processQueueData(mockQueueData.value, queueTimePeriod.value);
|
||||
});
|
||||
|
||||
const queueChartOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true, position: 'top' },
|
||||
},
|
||||
scales: {
|
||||
y1: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: 'Count' },
|
||||
grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
|
||||
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
|
||||
const conversionRate2025 = [2.5, 3.1, 4.0, 4.5, 4.2, 5.5, 6.0, 5.8, 5.7, 5.2, 6.5, 7.5];
|
||||
|
||||
const barData = computed(() => {
|
||||
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
|
||||
const conversionData = activeYear.value === '2024' ? [2.0, 2.5, 3.5, 3.8, 3.6, 4.5, 5.0, 5.2, 4.8, 4.5, 5.5, 6.0] : conversionRate2025;
|
||||
|
||||
return {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
|
||||
datasets: [
|
||||
{
|
||||
label: `Total Registrants`,
|
||||
backgroundColor: '#FFB95F',
|
||||
data: dataForYear,
|
||||
yAxisID: 'y1',
|
||||
type: 'bar',
|
||||
borderRadius: 6,
|
||||
},
|
||||
{
|
||||
label: `Conv. Rate (%)`,
|
||||
borderColor: '#0671E0',
|
||||
backgroundColor: 'transparent',
|
||||
data: conversionData,
|
||||
yAxisID: 'y2',
|
||||
type: 'line',
|
||||
pointRadius: 5,
|
||||
pointBackgroundColor: '#0663C7',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const barOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true, position: 'top' },
|
||||
},
|
||||
scales: {
|
||||
y1: {
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: 'Registrants' },
|
||||
grid: { display: false }
|
||||
},
|
||||
y2: {
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
suggestedMax: 10,
|
||||
title: { display: true, text: 'Conv. Rate (%)' },
|
||||
grid: { drawOnChartArea: false }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const pieData = ref({
|
||||
labels: ['Offline Registrants', 'Online Registrants'],
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: ['#0671E0', '#FFB95F'],
|
||||
hoverBackgroundColor: ['#0053AD', '#FF9B1B'],
|
||||
data: [759, 1876],
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const pieOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label || '';
|
||||
const value = context.parsed || 0;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* Background */
|
||||
.dashboard-bg {
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.dashboard-title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-900);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-700);
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
/* Export Button */
|
||||
.export-btn {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.2);
|
||||
}
|
||||
|
||||
/* Date Chip */
|
||||
.date-chip {
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
}
|
||||
|
||||
/* Stat Cards */
|
||||
.stat-card {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stat-card-primary {
|
||||
border-left-color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.stat-card-secondary {
|
||||
border-left-color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Chart Cards */
|
||||
.chart-card {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
min-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 16px 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.period-toggle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.year-chip {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.year-chip:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.live-chip {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.chart-card .v-card-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
color: var(--color-neutral-900);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.dashboard-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.d-flex.justify-space-between {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.d-flex.align-center .mr-4 {
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6">
|
||||
|
||||
<!-- Colored Header Banner -->
|
||||
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
|
||||
<v-icon size="48" color="white" class="mr-4">mdi-clipboard-list-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Klinik Ruang Admin</h1>
|
||||
</v-card>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card class="pa-6 rounded-xl elevation-2 mb-4">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
GENERATE TIKET
|
||||
</v-card-title>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
label="Masukkan Barcode"
|
||||
variant="solo"
|
||||
density="compact"
|
||||
hide-details
|
||||
flat
|
||||
class="mr-4 barcode-input"
|
||||
v-model="barcodeInput"
|
||||
@keyup.enter="generateTicket"
|
||||
></v-text-field>
|
||||
<v-chip color="#B71C1C" class="text-caption font-weight-bold text-white chip-warning">
|
||||
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Pasien Klinik Ruang Admin
|
||||
</v-card-title>
|
||||
|
||||
<div class="d-flex justify-space-between align-center my-3">
|
||||
<div class="d-flex align-center">
|
||||
<span>Show</span>
|
||||
<v-select
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
:items="[10, 25, 50, 100]"
|
||||
class="mx-2 select-items"
|
||||
hide-details
|
||||
v-model="itemsPerPage"
|
||||
></v-select>
|
||||
<span>entries</span>
|
||||
</div>
|
||||
<v-text-field
|
||||
label="Search"
|
||||
variant="solo"
|
||||
density="compact"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
style="max-width: 200px"
|
||||
v-model="searchQuery"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<v-table class="mt-3 custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in klinikRuangAdminHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="paginatedItems.length === 0">
|
||||
<td :colspan="klinikRuangAdminHeaders.length" class="text-center text-grey">
|
||||
Tidak ada data yang tersedia
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(item, index) in paginatedItems" :key="item.no">
|
||||
<td>{{ (currentPage - 1) * itemsPerPage + index + 1 }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.noAntrianKlinik }}</td>
|
||||
<td>{{ item.noAntrianRuang }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-btn color="success" size="small" class="text-white">Action</v-btn>
|
||||
</td>
|
||||
<td>{{ item.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>Showing {{ showingFrom }} to {{ showingTo }} of {{ filteredItems.length }} entries</span>
|
||||
<div class="d-flex">
|
||||
<v-btn size="small" variant="flat" :disabled="currentPage === 1" @click="prevPage" class="pagination-btn">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPage === totalPages" @click="nextPage" class="pagination-btn ml-2">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// === Data Dummy untuk Tabel ===
|
||||
const klinikRuangAdminHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'No Antrian Klinik' },
|
||||
{ text: 'No Antrian Ruang' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Action' },
|
||||
{ text: 'Status' },
|
||||
];
|
||||
|
||||
const allItems = ref([
|
||||
{ no: 1, barcode: '008730180085', noRekamedik: 'RM001', noAntrian: 'A001', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
|
||||
{ no: 2, barcode: '008730180086', noRekamedik: 'RM002', noAntrian: 'A002', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Proses' },
|
||||
{ no: 3, barcode: '008730180087', noRekamedik: 'RM003', noAntrian: 'A003', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||
{ no: 4, barcode: '008730180088', noRekamedik: 'RM004', noAntrian: 'A004', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
|
||||
{ no: 5, barcode: '008730180089', noRekamedik: 'RM005', noAntrian: 'A005', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Proses' },
|
||||
{ no: 6, barcode: '008730180090', noRekamedik: 'RM006', noAntrian: 'A006', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||
{ no: 7, barcode: '008730180091', noRekamedik: 'RM007', noAntrian: 'A007', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Selesai' },
|
||||
{ no: 8, barcode: '008730180092', noRekamedik: 'RM008', noAntrian: 'A008', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Proses' },
|
||||
{ no: 9, barcode: '008730180093', noRekamedik: 'RM009', noAntrian: 'A009', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||
{ no: 10, barcode: '008730180094', noRekamedik: 'RM010', noAntrian: 'A010', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Selesai' },
|
||||
{ no: 11, barcode: '008730180095', noRekamedik: 'RM011', noAntrian: 'A011', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Proses' },
|
||||
]);
|
||||
|
||||
// === State untuk Paginasi dan Pencarian ===
|
||||
const itemsPerPage = ref(10);
|
||||
const currentPage = ref(1);
|
||||
const searchQuery = ref('');
|
||||
const barcodeInput = ref('');
|
||||
|
||||
// === Computed Properties untuk Filter dan Paginasi ===
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return allItems.value;
|
||||
}
|
||||
const searchLower = searchQuery.value.toLowerCase();
|
||||
return allItems.value.filter(item => {
|
||||
return Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||
const end = start + itemsPerPage.value;
|
||||
return filteredItems.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const showingFrom = computed(() => {
|
||||
if (filteredItems.value.length === 0) return 0;
|
||||
return (currentPage.value - 1) * itemsPerPage.value + 1;
|
||||
});
|
||||
|
||||
const showingTo = computed(() => {
|
||||
const end = currentPage.value * itemsPerPage.value;
|
||||
return Math.min(end, filteredItems.value.length);
|
||||
});
|
||||
|
||||
// === Fungsi untuk Paginasi dan Pencarian ===
|
||||
const prevPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const generateTicket = () => {
|
||||
// Logika untuk menambahkan data ke tabel
|
||||
if (barcodeInput.value) {
|
||||
// Hapus karakter non-digit jika ada
|
||||
const cleanedBarcode = barcodeInput.value.replace(/\D/g, '');
|
||||
const newNo = allItems.value.length + 1;
|
||||
const newItem = {
|
||||
no: newNo,
|
||||
barcode: cleanedBarcode,
|
||||
noRekamedik: `RM${String(newNo).padStart(3, '0')}`,
|
||||
noAntrian: `A${String(newNo).padStart(3, '0')}`,
|
||||
noAntrianKlinik: 'K1',
|
||||
noAntrianRuang: 'R1',
|
||||
shift: 'Pagi',
|
||||
pembayaran: 'Tunai',
|
||||
status: 'Menunggu'
|
||||
};
|
||||
allItems.value.unshift(newItem); // Tambahkan item baru di paling depan
|
||||
barcodeInput.value = ''; // Reset input
|
||||
currentPage.value = 1; // Kembali ke halaman pertama setelah menambahkan data
|
||||
}
|
||||
};
|
||||
|
||||
// === Watcher untuk mereset halaman saat filter atau items per page berubah ===
|
||||
watch([searchQuery, itemsPerPage], () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles to make the page more lively */
|
||||
.v-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* .header-banner {
|
||||
background: linear-gradient(45deg, #42a5f5, #1565c0); /* Blue gradient */
|
||||
/* color: white;
|
||||
padding: 24px;
|
||||
} */
|
||||
|
||||
.header-banner {
|
||||
background: linear-gradient(45deg, #1A237E, #283593); /* Dark Blue gradient */
|
||||
color: white;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
|
||||
.barcode-input .v-field--variant-solo {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chip-warning {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.select-items {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.select-items .v-field--variant-solo {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.custom-table :deep(th) {
|
||||
background-color: #e0e0e0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-table :deep(tr) {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
margin: 0 4px;
|
||||
background-color: #ffb38a !important;
|
||||
}
|
||||
|
||||
.next-queue-card {
|
||||
background: linear-gradient(135deg, #00A896, #00796B); /* Teal gradient */
|
||||
}
|
||||
|
||||
.current-queue-number {
|
||||
background-color: white;
|
||||
border: 4px solid #00A896;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #00A896 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,635 +0,0 @@
|
||||
<template>
|
||||
<div class="pasien-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-account-group</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">List Pasien</h1>
|
||||
<p class="page-subtitle">Senin, 15 September 2025 - Data Master Pasien</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-chip
|
||||
color="success"
|
||||
variant="flat"
|
||||
class="mr-2"
|
||||
>
|
||||
Total {{ pasienData.length }} Pasien
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="white"
|
||||
variant="flat"
|
||||
class="text-primary"
|
||||
>
|
||||
Status: Aktif
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls
|
||||
<v-card class="filter-controls-card mb-4" elevation="2">
|
||||
<v-card-text class="py-4">
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="8">
|
||||
<div class="d-flex align-center flex-wrap gap-10">
|
||||
<span class="text-subtitle-1 font-weight-medium">Filter Cepat:</span>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="filterByStatus('Tunggu Daftar')"
|
||||
>
|
||||
<v-icon start size="16">mdi-clock-outline</v-icon>
|
||||
Tunggu Daftar
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="filterByStatus('Selesai')"
|
||||
>
|
||||
<v-icon start size="16">mdi-check-circle</v-icon>
|
||||
Selesai
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="filterByKlinik('JKN')"
|
||||
>
|
||||
<v-icon start size="16">mdi-card-account-details</v-icon>
|
||||
JKN
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="info"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="resetFilter()"
|
||||
>
|
||||
<v-icon start size="16">mdi-refresh</v-icon>
|
||||
Reset
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<div class="d-flex justify-end gap-2">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="handleExportLaporan"
|
||||
:loading="loading"
|
||||
>
|
||||
<v-icon start>mdi-file-excel</v-icon>
|
||||
Export Laporan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="handleExportLaporanPerKlinik"
|
||||
:loading="loading"
|
||||
>
|
||||
<v-icon start>mdi-hospital-building</v-icon>
|
||||
Export Per Klinik
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card> -->
|
||||
|
||||
<!-- Main Patients Table -->
|
||||
<v-card class="main-table-card mb-4" elevation="2">
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-6">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="mr-2">mdi-table</v-icon>
|
||||
<span class="text-h6 font-weight-bold">DATA PASIEN</span>
|
||||
</div>
|
||||
<v-chip color="info" variant="flat">
|
||||
{{ filteredData.length }} dari {{ pasienData.length }} pasien
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<TabelListPasien
|
||||
:items="filteredData"
|
||||
@search="handleSearch"
|
||||
@export-laporan="handleExportLaporan"
|
||||
@export-laporan-per-klinik="handleExportLaporanPerKlinik"
|
||||
/>
|
||||
</v-card>
|
||||
|
||||
Statistics Cards
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="success" class="mb-2">mdi-account-check</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-success">{{ getStatsByStatus('Tunggu Daftar') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Tunggu Daftar</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="info" class="mb-2">mdi-barcode</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-info">{{ getStatsByStatus('Barcode') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Barcode</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="warning" class="mb-2">mdi-wifi</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-warning">{{ getStatsByKeterangan('Online') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Online</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="error" class="mb-2">mdi-wifi-off</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-error">{{ getStatsByKeterangan('Offline') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Offline</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<v-overlay v-model="loading" class="align-center justify-center">
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
size="64"
|
||||
/>
|
||||
</v-overlay>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="3000"
|
||||
location="top right"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
<template v-slot:actions>
|
||||
<v-btn
|
||||
icon
|
||||
@click="snackbar.show = false"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import TabelListPasien from '~/components/TabelListPasien.vue'
|
||||
|
||||
// Meta untuk SEO
|
||||
definePageMeta({
|
||||
title: 'List Pasien',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
// Reactive states
|
||||
const loading = ref(false)
|
||||
const currentFilter = ref('')
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// Sample data
|
||||
const pasienData = ref([
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3507264104730004',
|
||||
rm: '11412584',
|
||||
barcode: '250627100001',
|
||||
noAntrian: 'HQ1001',
|
||||
klinik: 'HOM',
|
||||
fullName: 'Binti Almatul',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3504096063630001',
|
||||
rm: '',
|
||||
barcode: '250627100002',
|
||||
noAntrian: 'QB1001',
|
||||
klinik: 'KANDUNGAN',
|
||||
fullName: 'maret kumalal',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3507114102250002',
|
||||
rm: '11555560',
|
||||
barcode: '250627100003',
|
||||
noAntrian: 'QB1002',
|
||||
klinik: 'KANDUNGAN',
|
||||
fullName: 'ayu rafti lelu amanda',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3508185040150002',
|
||||
rm: '11333655',
|
||||
barcode: '250627100004',
|
||||
noAntrian: 'AN1001',
|
||||
klinik: 'ANAK',
|
||||
fullName: 'Erin Wahyuni',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3515085040110004',
|
||||
rm: '11585554',
|
||||
barcode: '250627100005',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: 'Yohana Karina Pusplta Sari',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3506246105750002',
|
||||
rm: '11527608',
|
||||
barcode: '250627100006',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: 'Elok Suharsti',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100007',
|
||||
noAntrian: 'HQ1002',
|
||||
klinik: 'HOM',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100008',
|
||||
noAntrian: 'IP1002',
|
||||
klinik: 'IPD',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100009',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100010',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
}
|
||||
])
|
||||
|
||||
// Computed properties
|
||||
const filteredData = computed(() => {
|
||||
if (!currentFilter.value) {
|
||||
return pasienData.value
|
||||
}
|
||||
|
||||
return pasienData.value.filter(item => {
|
||||
return item.status === currentFilter.value ||
|
||||
item.pembayaran === currentFilter.value ||
|
||||
item.keterangan === currentFilter.value
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (message, color = 'success') => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message,
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
const filterByStatus = (status) => {
|
||||
currentFilter.value = status
|
||||
showSnackbar(`Filter diterapkan: ${status}`, 'info')
|
||||
}
|
||||
|
||||
const filterByKlinik = (pembayaran) => {
|
||||
currentFilter.value = pembayaran
|
||||
showSnackbar(`Filter diterapkan: ${pembayaran}`, 'info')
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
currentFilter.value = ''
|
||||
showSnackbar('Filter direset', 'success')
|
||||
}
|
||||
|
||||
const getStatsByStatus = (status) => {
|
||||
return pasienData.value.filter(item => item.status === status).length
|
||||
}
|
||||
|
||||
const getStatsByKeterangan = (keterangan) => {
|
||||
return pasienData.value.filter(item => item.keterangan === keterangan).length
|
||||
}
|
||||
|
||||
const handleSearch = async (filters) => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log('Search filters:', filters)
|
||||
showSnackbar('Data berhasil difilter', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error searching data:', error)
|
||||
showSnackbar('Gagal memfilter data', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportLaporan = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
console.log('Exporting laporan pasien...')
|
||||
showSnackbar('Laporan pasien berhasil diexport', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error exporting laporan:', error)
|
||||
showSnackbar('Gagal export laporan pasien', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportLaporanPerKlinik = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
console.log('Exporting laporan pasien per klinik...')
|
||||
showSnackbar('Laporan pasien per klinik berhasil diexport', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error exporting laporan per klinik:', error)
|
||||
showSnackbar('Gagal export laporan pasien per klinik', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPasienData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log('Patient data loaded successfully')
|
||||
} catch (error) {
|
||||
console.error('Error fetching patient data:', error)
|
||||
showSnackbar('Gagal memuat data pasien', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchPasienData()
|
||||
})
|
||||
|
||||
// Head untuk SEO
|
||||
useHead({
|
||||
title: 'List Pasien - Antrean RSSA',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Daftar master data seluruh pasien rumah sakit'
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pasien-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* .filter-controls-card, */
|
||||
.main-table-card,
|
||||
.stats-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Enhanced table styling */
|
||||
.main-table-card :deep(.v-data-table th) {
|
||||
background: #fafbfc;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody tr:hover) {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody td) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.v-btn {
|
||||
text-transform: none !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-btn--size-default {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pasien-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* .filter-controls-card .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
} */
|
||||
|
||||
.filter-controls-card .v-col:last-child .d-flex {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filter-buttons-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-right .v-chip {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<v-container fluid fill-height class="login-background">
|
||||
|
||||
<div class="floating-medical-icon icon-1">
|
||||
<v-icon size="144">mdi-heart-pulse</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-2">
|
||||
<v-icon size="108">mdi-medical-bag</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-3">
|
||||
<v-icon size="126">mdi-stethoscope</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-4">
|
||||
<v-icon size="120">mdi-hospital-box</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-5">
|
||||
<v-icon size="96">mdi-pill</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-6">
|
||||
<v-icon size="114">mdi-bandage</v-icon>
|
||||
</div>
|
||||
|
||||
<v-row class="fill-height align-center justify-center">
|
||||
<v-col cols="12" class="d-flex justify-center align-center">
|
||||
<v-card class="main-card white-card rounded-xl pa-0" max-width="450" width="100%">
|
||||
<v-row class="ma-0">
|
||||
<v-col cols="12" class="login-section-box d-flex align-center pa-8">
|
||||
<div class="login-content w-100">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
|
||||
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column align-center text-center mb-6">
|
||||
<span class="text-h5 font-weight-bold app-title-dark">Antrean RSSA</span>
|
||||
<img
|
||||
src="/AntreanLogobg.png"
|
||||
alt="Antrean Logo"
|
||||
class="mt-3 hospital-logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-if="successMessage" type="success" class="mb-4">
|
||||
{{ successMessage }}
|
||||
</v-alert>
|
||||
|
||||
|
||||
|
||||
<v-btn
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
block
|
||||
rounded="lg"
|
||||
size="large"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<v-icon left>mdi-shield-key</v-icon>
|
||||
<span class="font-weight-bold">
|
||||
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
|
||||
</span>
|
||||
<v-icon right>mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-divider class="my-6 custom-divider-dark"></v-divider>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- <div class="text-center">
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = true"
|
||||
class="register-btn-dark"
|
||||
variant="outlined"
|
||||
block
|
||||
rounded="lg"
|
||||
size="large"
|
||||
>
|
||||
<v-icon left>mdi-account-plus</v-icon>
|
||||
<span class="font-weight-bold">Daftar Akun Baru</span>
|
||||
</v-btn>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span class="help-text-dark">
|
||||
Belum memiliki akun?
|
||||
</span>
|
||||
<br>
|
||||
<v-btn
|
||||
@click="showAdminContact = true"
|
||||
variant="text"
|
||||
color="#FF9B1B" size="small"
|
||||
class="contact-link-dark mt-1"
|
||||
>
|
||||
Hubungi Administrator
|
||||
</v-btn>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="text-center mt-4">
|
||||
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
|
||||
</div> -->
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- <v-dialog v-model="showRegistrationDialog" max-width="500">
|
||||
<v-card class="white-dialog rounded-xl">
|
||||
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
|
||||
<v-icon left color="#FF9B1B">mdi-account-plus</v-icon> Pendaftaran Akun Baru
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="text-grey-darken-2 pa-6">
|
||||
<div class="text-center mb-4">
|
||||
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-information</v-icon> </div>
|
||||
|
||||
<p class="text-body-1 mb-4">
|
||||
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
|
||||
</p>
|
||||
|
||||
<v-list class="transparent">
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-numeric-1-circle</v-icon> </template>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Hubungi Administrator IT Rumah Sakit
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-numeric-2-circle</v-icon> </template>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Siapkan dokumen identitas dan surat penugasan
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-numeric-3-circle</v-icon> </template>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Tunggu proses verifikasi dan aktivasi akun
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 bg-grey-lighten-5">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = false"
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
rounded
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = false; showAdminContact = true"
|
||||
color="#FF9B1B" rounded
|
||||
>
|
||||
<v-icon left>mdi-phone</v-icon>
|
||||
Hubungi Admin
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog> -->
|
||||
|
||||
<!-- <v-dialog v-model="showAdminContact" max-width="500">
|
||||
<v-card class="white-dialog rounded-xl">
|
||||
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
|
||||
<v-icon left color="#FF9B1B">mdi-account-tie</v-icon> Kontak Administrator
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="text-grey-darken-2 pa-6">
|
||||
<div class="text-center mb-4">
|
||||
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-phone-settings</v-icon> </div>
|
||||
|
||||
<v-list class="transparent">
|
||||
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-office-building</v-icon> </template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
|
||||
IT Support RSSA
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Bagian Teknologi Informasi
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-phone</v-icon> </template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
(0341) 343343 ext. 1234
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Telepon Internal
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-email</v-icon> </template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
it-support@rssa.malang.go.id
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Email Resmi
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#FF9B1B">mdi-clock</v-icon> </template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Senin - Jumat: 07:00 - 15:00
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Jam Operasional
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
color="orange" >
|
||||
<div class="text-grey-darken-2">
|
||||
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
|
||||
</div>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 bg-grey-lighten-5">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
@click="showAdminContact = false"
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
rounded
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="copyContactInfo"
|
||||
color="#FF9B1B" rounded
|
||||
>
|
||||
<v-icon left>mdi-content-copy</v-icon>
|
||||
Salin Info
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog> -->
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LoginResponse } from '~/types/auth'
|
||||
|
||||
// Use guest middleware to redirect if already authenticated
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
middleware:['auth','guest']
|
||||
})
|
||||
|
||||
// Reactive state
|
||||
const isLoading = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const successMessage = ref<string>('')
|
||||
const showRegistrationDialog = ref<boolean>(false)
|
||||
const showAdminContact = ref<boolean>(false)
|
||||
|
||||
// Hospital Profile URL
|
||||
const HOSPITAL_PROFILE_URL = 'https://rsusaifulanwar.jatimprov.go.id/v2/'
|
||||
|
||||
// Function to navigate to external hospital profile site
|
||||
const goToHospitalProfile = (): void => {
|
||||
window.open(HOSPITAL_PROFILE_URL, '_blank')
|
||||
}
|
||||
|
||||
// Check URL parameters for errors
|
||||
const route = useRoute()
|
||||
onMounted(() => {
|
||||
if (route.query.error) {
|
||||
errorMessage.value = decodeURIComponent(route.query.error as string)
|
||||
}
|
||||
})
|
||||
|
||||
// Custom login handler (pointing to Keycloak SSO logic)
|
||||
const handleLogin = async (): Promise<void> => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
console.log('Starting login process...')
|
||||
|
||||
// Call API route to initiate Keycloak login process
|
||||
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response?.success && response?.data?.authUrl) {
|
||||
console.log('Redirecting to Keycloak...')
|
||||
successMessage.value = 'Redirecting to Keycloak...'
|
||||
|
||||
// Redirect the user to the Keycloak authorization URL
|
||||
setTimeout(() => {
|
||||
window.location.href = response.data!.authUrl
|
||||
}, 500)
|
||||
} else {
|
||||
throw new Error('Failed to get authorization URL')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error)
|
||||
// Display the error message
|
||||
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
|
||||
} finally {
|
||||
// Only set isLoading back to false if no redirect is happening
|
||||
if (!successMessage.value.includes('Redirecting')) {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy contact information to clipboard
|
||||
const copyContactInfo = async (): Promise<void> => {
|
||||
// Updated contact info block from user's provided code
|
||||
const contactInfo = `
|
||||
IT Support RSSA
|
||||
Telepon: (0341) 343343 ext. 1234
|
||||
Email: it-support@rssa.malang.go.id
|
||||
Jam Operasional: Senin - Jumat, 07:00 - 15:00
|
||||
`.trim()
|
||||
|
||||
try {
|
||||
// Use the Clipboard API
|
||||
await navigator.clipboard.writeText(contactInfo)
|
||||
successMessage.value = 'Informasi kontak berhasil disalin!'
|
||||
showAdminContact.value = false
|
||||
|
||||
// Clear the success message after a short delay
|
||||
setTimeout(() => {
|
||||
successMessage.value = ''
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy contact info:', error)
|
||||
// Fallback or simple alert for error
|
||||
errorMessage.value = 'Gagal menyalin informasi kontak. Silakan coba salin manual.'
|
||||
|
||||
setTimeout(() => {
|
||||
errorMessage.value = ''
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="~/assets/styles/loginpage/login.css"></style>
|
||||
@@ -1,754 +0,0 @@
|
||||
<!-- pages/LoketAdmin.vue -->
|
||||
<template>
|
||||
<div class="loket-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Loket Admin</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-chip color="success" variant="flat" class="mr-2">
|
||||
Total {{ totalPasien }} Pasien
|
||||
</v-chip>
|
||||
<v-chip color="white" variant="flat" class="text-primary">
|
||||
Max 150 Pasien
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<v-card class="next-patient-card mb-4" elevation="2">
|
||||
<v-card-text class="text-center pa-6">
|
||||
<v-chip color="success" variant="flat" class="mb-3" size="large">
|
||||
<v-icon start>mdi-account-arrow-right</v-icon>
|
||||
PASIEN SELANJUTNYA
|
||||
</v-chip>
|
||||
<div class="text-h4 font-weight-bold mb-2 text-success">
|
||||
{{ nextPatient ? nextPatient.noAntrian.split(" |")[0] : "UM1004" }}
|
||||
</div>
|
||||
<div class="text-body-1 mb-4 text-grey-darken-1">
|
||||
Klik tombol hijau untuk memanggil
|
||||
</div>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-8"
|
||||
@click="callNext"
|
||||
:disabled="!nextPatient"
|
||||
>
|
||||
<v-icon start>mdi-microphone</v-icon>
|
||||
PANGGIL NEXT
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
<!-- Combined Control Section -->
|
||||
<v-row class="mb-4">
|
||||
<!-- Left Side: Call Controls and Current Processing -->
|
||||
<v-col cols="12" lg="6">
|
||||
<!-- Current Patient Processing Card -->
|
||||
<v-card
|
||||
v-if="currentProcessingPatient"
|
||||
class="current-processing-card"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div class="patient-info">
|
||||
<v-chip color="primary" variant="flat" class="mb-2">
|
||||
<v-icon start>mdi-account-clock</v-icon>
|
||||
SEDANG DIPROSES
|
||||
</v-chip>
|
||||
<div class="text-h5 font-weight-bold mb-1">
|
||||
{{ currentProcessingPatient.noAntrian.split(" |")[0] }}
|
||||
</div>
|
||||
<div class="text-subtitle-1 text-grey-darken-1">
|
||||
{{ currentProcessingPatient.barcode }} |
|
||||
{{ currentProcessingPatient.klinik }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
class="mr-2"
|
||||
@click="processPatient(currentProcessingPatient, 'check-in')"
|
||||
>
|
||||
<v-icon start>mdi-check-circle</v-icon>
|
||||
Check In
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
class="mr-2"
|
||||
@click="processPatient(currentProcessingPatient, 'terlambat')"
|
||||
>
|
||||
<v-icon start>mdi-clock-alert</v-icon>
|
||||
Terlambat
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
@click="processPatient(currentProcessingPatient, 'pending')"
|
||||
>
|
||||
<v-icon start>mdi-pause-circle</v-icon>
|
||||
Pending
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Side: Patient Queue Info -->
|
||||
<v-col cols="12" lg="6">
|
||||
<!-- Next Patient Card -->
|
||||
<!-- Quota Info Card -->
|
||||
<v-card class="quota-info-card" elevation="2">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="text-center">
|
||||
<div class="text-h6 font-weight-medium mb-3">
|
||||
Panggil Antrean Anjungan
|
||||
</div>
|
||||
<v-row class="mb-3">
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-grey-darken-1">Kuota</div>
|
||||
<div class="text-h4 font-weight-bold">150</div>
|
||||
</v-col>
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-grey-darken-1">Tersedia</div>
|
||||
<div class="text-h4 font-weight-bold text-success">
|
||||
{{ 150 - quotaUsed }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="text-body-2 text-grey-darken-1 mb-2">
|
||||
Total Quota Terpakai: {{ quotaUsed }}
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="(quotaUsed / 150) * 100"
|
||||
color="success"
|
||||
height="8"
|
||||
rounded
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card-text class="call-controls-card align-center py-4">
|
||||
<div class="text-subtitle-1 font-weight-medium"
|
||||
>Panggil Pasien:</div>
|
||||
<div class="d-flex align-center flex-wrap mb-3">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(1)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">1</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="info"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(5)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">5</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(10)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">10</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(20)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">20</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Di Loket Patients Table -->
|
||||
<v-card class="main-table-card mb-4" elevation="2">
|
||||
<TabelData
|
||||
:headers="diLoketHeaders"
|
||||
:items="diLoketPatients"
|
||||
title="DATA PASIEN - DI LOKET"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="info"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'aktifkan')"
|
||||
>
|
||||
Aktifkan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'proses')"
|
||||
>
|
||||
Proses
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.jamPanggil="{ item }">
|
||||
<span :class="getRowClass(item)">{{ item.jamPanggil }}</span>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
|
||||
<!-- Terlambat Patients Table -->
|
||||
<v-card
|
||||
class="late-table-card mb-4"
|
||||
elevation="2"
|
||||
v-if="terlambatPatients.length > 0"
|
||||
>
|
||||
<TabelData
|
||||
:headers="terlambatHeaders"
|
||||
:items="terlambatPatients"
|
||||
title="INFO PASIEN LAPOR TERLAMBAT"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'aktifkan')"
|
||||
>
|
||||
Aktifkan
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
|
||||
<!-- Pending Patients Table -->
|
||||
<v-card
|
||||
class="pending-table-card mb-4"
|
||||
elevation="2"
|
||||
v-if="pendingPatients.length > 0"
|
||||
>
|
||||
<TabelData
|
||||
:headers="pendingHeaders"
|
||||
:items="pendingPatients"
|
||||
title="INFO PASIEN PENDING"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'proses')"
|
||||
>
|
||||
Proses
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="3000"
|
||||
location="top right"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
|
||||
<template v-slot:actions>
|
||||
<v-btn icon @click="snackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import TabelData from "../components/TabelData.vue";
|
||||
|
||||
// Reactive data
|
||||
const snackbar = ref(false);
|
||||
const snackbarText = ref("");
|
||||
const snackbarColor = ref("success");
|
||||
const quotaUsed = ref(5);
|
||||
const currentProcessingPatient = ref(null);
|
||||
|
||||
// Base patient data - semua pasien yang belum dipanggil
|
||||
const allPatients = ref([
|
||||
{
|
||||
no: 1,
|
||||
jamPanggil: "12:49",
|
||||
barcode: "250811100163",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
shift: "Shift 1",
|
||||
klinik: "KANDUNGAN",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting", // waiting, di-loket, terlambat, pending, processed
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
jamPanggil: "10:52",
|
||||
barcode: "250811100155",
|
||||
noAntrian: "UM1002 | Online - 250811100155",
|
||||
shift: "Shift 1",
|
||||
klinik: "IPD",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
jamPanggil: "09:30",
|
||||
barcode: "250811100200",
|
||||
noAntrian: "UM1003 | Online - 250811100200",
|
||||
shift: "Shift 1",
|
||||
klinik: "SARAF",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
jamPanggil: "14:15",
|
||||
barcode: "250811100210",
|
||||
noAntrian: "UM1004 | Online - 250811100210",
|
||||
shift: "Shift 1",
|
||||
klinik: "THT",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
},
|
||||
...Array.from({ length: 16 }, (_, i) => ({
|
||||
no: i + 5,
|
||||
jamPanggil: `${String(Math.floor(Math.random() * 12) + 1).padStart(2, "0")}:${String(Math.floor(Math.random() * 60)).padStart(2, "0")}`,
|
||||
barcode: `25081110${String(i + 300).padStart(4, "0")}`,
|
||||
noAntrian: `UM100${i + 5} | Online - 25081110${String(i + 300).padStart(4, "0")}`,
|
||||
shift: "Shift 1",
|
||||
klinik: ["KANDUNGAN", "IPD", "THT", "SARAF"][Math.floor(Math.random() * 4)],
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
})),
|
||||
]);
|
||||
|
||||
// Computed properties for different status tables
|
||||
const diLoketPatients = computed(() =>
|
||||
allPatients.value.filter((patient) => patient.status === "di-loket")
|
||||
);
|
||||
|
||||
const terlambatPatients = computed(() =>
|
||||
allPatients.value.filter((patient) => patient.status === "terlambat")
|
||||
);
|
||||
|
||||
const pendingPatients = computed(() =>
|
||||
allPatients.value.filter((patient) => patient.status === "pending")
|
||||
);
|
||||
|
||||
const nextPatient = computed(() => {
|
||||
return allPatients.value.find((patient) => patient.status === "waiting");
|
||||
});
|
||||
|
||||
const totalPasien = computed(() => allPatients.value.length);
|
||||
|
||||
// Headers for different tables
|
||||
const diLoketHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false, width: "60px" },
|
||||
{ title: "Jam Panggil", value: "jamPanggil", sortable: true, width: "100px" },
|
||||
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
|
||||
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Fast Track", value: "fastTrack", sortable: true, width: "100px" },
|
||||
{ title: "Pembayaran", value: "pembayaran", sortable: true, width: "100px" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false, width: "200px" },
|
||||
]);
|
||||
|
||||
const terlambatHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false, width: "60px" },
|
||||
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
|
||||
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false, width: "100px" },
|
||||
]);
|
||||
|
||||
const pendingHeaders = ref([
|
||||
{ title: "#", value: "no", sortable: false, width: "60px" },
|
||||
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
|
||||
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Fast Track", value: "fastTrack", sortable: true, width: "100px" },
|
||||
{ title: "Pembayaran", value: "pembayaran", sortable: true, width: "100px" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false, width: "100px" },
|
||||
]);
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (text, color = "success") => {
|
||||
snackbarText.value = text;
|
||||
snackbarColor.value = color;
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const callMultiplePatients = (count) => {
|
||||
const waitingPatients = allPatients.value.filter(
|
||||
(patient) => patient.status === "waiting"
|
||||
);
|
||||
const patientsToCall = waitingPatients.slice(0, count);
|
||||
|
||||
if (patientsToCall.length === 0) {
|
||||
showSnackbar("Tidak ada pasien yang menunggu", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check quota
|
||||
if (quotaUsed.value + patientsToCall.length > 150) {
|
||||
showSnackbar("Quota tidak mencukupi", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Move patients to "di-loket" status
|
||||
patientsToCall.forEach((patient) => {
|
||||
patient.status = "di-loket";
|
||||
});
|
||||
|
||||
quotaUsed.value += patientsToCall.length;
|
||||
showSnackbar(`Memanggil ${patientsToCall.length} pasien ke loket`, "success");
|
||||
};
|
||||
|
||||
const callNext = () => {
|
||||
if (!nextPatient.value) {
|
||||
showSnackbar("Tidak ada pasien selanjutnya", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (quotaUsed.value >= 150) {
|
||||
showSnackbar("Quota sudah penuh", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Move next patient to processing
|
||||
nextPatient.value.status = "di-loket";
|
||||
currentProcessingPatient.value = nextPatient.value;
|
||||
quotaUsed.value++;
|
||||
|
||||
showSnackbar(
|
||||
`Memanggil pasien ${nextPatient.value.noAntrian.split(" |")[0]}`,
|
||||
"success"
|
||||
);
|
||||
};
|
||||
|
||||
const processPatient = (patient, action) => {
|
||||
const patientCode = patient.noAntrian.split(" |")[0];
|
||||
|
||||
switch (action) {
|
||||
case "check-in":
|
||||
patient.status = "processed";
|
||||
if (currentProcessingPatient.value?.no === patient.no) {
|
||||
currentProcessingPatient.value = null;
|
||||
}
|
||||
showSnackbar(`Pasien ${patientCode} berhasil check in`, "success");
|
||||
break;
|
||||
|
||||
case "terlambat":
|
||||
patient.status = "terlambat";
|
||||
if (currentProcessingPatient.value?.no === patient.no) {
|
||||
currentProcessingPatient.value = null;
|
||||
}
|
||||
showSnackbar(`Pasien ${patientCode} ditandai terlambat`, "warning");
|
||||
break;
|
||||
|
||||
case "pending":
|
||||
patient.status = "pending";
|
||||
if (currentProcessingPatient.value?.no === patient.no) {
|
||||
currentProcessingPatient.value = null;
|
||||
}
|
||||
showSnackbar(`Pasien ${patientCode} di-pending`, "info");
|
||||
break;
|
||||
|
||||
case "aktifkan":
|
||||
if (patient.status === "terlambat") {
|
||||
patient.status = "di-loket";
|
||||
showSnackbar(`Pasien ${patientCode} diaktifkan kembali`, "success");
|
||||
}
|
||||
break;
|
||||
|
||||
case "proses":
|
||||
currentProcessingPatient.value = patient;
|
||||
showSnackbar(`Memproses pasien ${patientCode}`, "info");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === "current") {
|
||||
return "text-success font-weight-bold";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loket-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.call-controls-card,
|
||||
.next-patient-card,
|
||||
.current-processing-card,
|
||||
.quota-info-card,
|
||||
.main-table-card,
|
||||
.late-table-card,
|
||||
.pending-table-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.next-patient-card {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.current-processing-card {
|
||||
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
|
||||
border: 2px solid rgba(255, 152, 0, 0.2);
|
||||
}
|
||||
|
||||
.quota-info-card {
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||
border: 2px solid rgba(33, 150, 243, 0.2);
|
||||
}
|
||||
|
||||
.patient-info .text-h5 {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Enhanced table styling */
|
||||
.main-table-card :deep(.v-data-table th),
|
||||
.late-table-card :deep(.v-data-table th),
|
||||
.pending-table-card :deep(.v-data-table th) {
|
||||
background: #fafbfc;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody tr:hover),
|
||||
.late-table-card :deep(.v-data-table tbody tr:hover),
|
||||
.pending-table-card :deep(.v-data-table tbody tr:hover) {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody td),
|
||||
.late-table-card :deep(.v-data-table tbody td),
|
||||
.pending-table-card :deep(.v-data-table tbody td) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.v-btn {
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.v-btn--size-small {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Success text color */
|
||||
.text-success {
|
||||
color: #4caf50 !important;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.current-processing-card .d-flex {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-buttons .v-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Stack layout vertically on medium screens */
|
||||
.call-controls-card .d-flex {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.loket-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.call-controls-card .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.call-controls-card .v-btn {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Stack all control buttons vertically on mobile */
|
||||
.call-controls-card .d-flex.flex-wrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.call-controls-card .v-btn {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-buttons .v-btn {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.next-patient-card .text-h4 {
|
||||
font-size: 1.75rem !important;
|
||||
}
|
||||
|
||||
.current-processing-card .patient-info .text-h5 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.quota-info-card .text-h4 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-0">
|
||||
|
||||
<div class="pa-4 pb-0">
|
||||
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding"> <v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
|
||||
<v-icon left class="mr-2" color="blue-darken-3">mdi-heart-pulse</v-icon>
|
||||
Monitoring Pasien
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-chip color="blue-darken-1" text-color="white" class="font-weight-medium admin-chip"> <v-icon start>mdi-account-tie</v-icon>
|
||||
Admin
|
||||
</v-chip>
|
||||
</v-toolbar>
|
||||
</div>
|
||||
|
||||
<v-container class="mt-6">
|
||||
|
||||
<v-card flat class="mb-4">
|
||||
<v-tabs
|
||||
v-model="activeTab"
|
||||
color="primary"
|
||||
align-tabs="start"
|
||||
show-arrows
|
||||
class="mb-4"
|
||||
>
|
||||
<v-tab value="all" @click="filterByStatus('all')">
|
||||
Semua Pasien <v-badge color="grey-lighten-1" :content="totalCount" inline class="ml-2"></v-badge>
|
||||
</v-tab>
|
||||
<v-tab value="Menunggu Poli" @click="filterByStatus('Menunggu Poli')">
|
||||
Menunggu Poli <v-badge color="orange-lighten-1" :content="waitingCount" inline class="ml-2"></v-badge>
|
||||
</v-tab>
|
||||
<v-tab value="Diperiksa Dokter" @click="filterByStatus('Diperiksa Dokter')">
|
||||
Diperiksa Dokter <v-badge color="green-lighten-1" :content="examiningCount" inline class="ml-2"></v-badge>
|
||||
</v-tab>
|
||||
<v-tab value="Selesai Pelayanan" @click="filterByStatus('Selesai Pelayanan')">
|
||||
Selesai Pelayanan <v-badge color="blue-lighten-1" :content="doneCount" inline class="ml-2"></v-badge>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mb-6 pa-4 elevation-1">
|
||||
<v-row class="align-center">
|
||||
<v-col cols="12" sm="3">
|
||||
<v-text-field label="No. RM" v-model="filters.rm_number" variant="outlined" density="compact" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<!-- <v-col cols="12" sm="3">
|
||||
<v-text-field label="No. Kode QR" v-model="filters.qr_code" variant="outlined" density="compact" hide-details></v-text-field>
|
||||
</v-col> -->
|
||||
<v-col cols="12" sm="2">
|
||||
<v-text-field label="No. Antrean" v-model="filters.queue_number" variant="outlined" density="compact" hide-details type="number"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="2">
|
||||
<v-select label="Layanan" v-model="filters.service" :items="['Klinik Jiwa', 'Radiologi', 'Fisioterapi', 'Klinik Umum']" variant="outlined" density="compact" hide-details clearable></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="2">
|
||||
<v-btn color="blue-lighten-1" block height="40" @click="applyFilters">Cari</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="paginatedPatients"
|
||||
:items-per-page="10"
|
||||
class="elevation-1"
|
||||
:search="currentSearchTerm"
|
||||
>
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip :color="getStatusColor(item.status)" size="small" class="font-weight-medium" label>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<v-btn size="small" color="orange-darken-1" dark @click="viewPatient(item.id)">
|
||||
Lihat
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:no-data>
|
||||
<v-alert :value="true" color="info" icon="mdi-information-outline">
|
||||
Tidak ada data pasien untuk ditampilkan.
|
||||
</v-alert>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// === STATE MANAGEMENT ===
|
||||
const activeTab = ref('all');
|
||||
|
||||
// Filter Model: Menampung input dari form pencarian
|
||||
const filters = ref({
|
||||
rm_number: '',
|
||||
// qr_code: '',
|
||||
queue_number: '', // Filter baru
|
||||
service: null, // Filter Layanan
|
||||
});
|
||||
|
||||
// State untuk menyimpan hasil filtering
|
||||
const filteredPatientList = ref([]);
|
||||
const currentSearchTerm = ref('');
|
||||
|
||||
// Table Headers
|
||||
const headers = [
|
||||
{ title: 'No. RM', key: 'rm_number' },
|
||||
{ title: 'Nama', key: 'name' },
|
||||
{ title: 'No. Antrean', key: 'queue_number' },
|
||||
{ title: 'Layanan', key: 'service' },
|
||||
{ title: 'Status', key: 'status' },
|
||||
{ title: 'Aksi', key: 'aksi', sortable: false },
|
||||
];
|
||||
|
||||
// Mock Patient Data
|
||||
const ALL_PATIENTS_DATA = [
|
||||
{ id: '11111111', rm_number: '11111111', name: 'Ragil Bayu N.', queue_number: 1, service: 'Klinik Jiwa', status: 'Diperiksa Dokter', qr_code: 'QR123' },
|
||||
{ id: '22222222', rm_number: '22222222', name: 'Siti Aisyah', queue_number: 5, service: 'Radiologi', status: 'Menunggu Poli', qr_code: 'QR456' },
|
||||
{ id: '33333333', rm_number: '33333333', name: 'Ahmad Dani', queue_number: 2, service: 'Klinik Jiwa', status: 'Selesai Pelayanan', qr_code: 'QR789' },
|
||||
{ id: '44444444', rm_number: '44444444', name: 'Dewi Sartika', queue_number: 6, service: 'Fisioterapi', status: 'Menunggu Poli', qr_code: 'QR101' },
|
||||
{ id: '55555555', rm_number: '55555555', name: 'Joko Susilo', queue_number: 3, service: 'Klinik Umum', status: 'Diperiksa Dokter', qr_code: 'QR112' },
|
||||
{ id: '66666666', rm_number: '66666666', name: 'Budi Santoso', queue_number: 4, service: 'Radiologi', status: 'Menunggu Poli', qr_code: 'QR131' },
|
||||
];
|
||||
|
||||
// Initialize the filtered list with all data
|
||||
filteredPatientList.value = [...ALL_PATIENTS_DATA];
|
||||
|
||||
// === FILTERING LOGIC ===
|
||||
|
||||
// 1. Logic Pencarian Gabungan saat tombol "Cari" ditekan
|
||||
const applyFilters = () => {
|
||||
// 1. Filter berdasarkan teks di semua kolom yang relevan (RM, QR, Antrean, Nama)
|
||||
let textSearch = '';
|
||||
|
||||
// Gabungkan semua nilai filter menjadi satu string pencarian
|
||||
if (filters.value.rm_number) textSearch += filters.value.rm_number.toLowerCase() + ' ';
|
||||
if (filters.value.qr_code) textSearch += filters.value.qr_code.toLowerCase() + ' ';
|
||||
if (filters.value.queue_number) textSearch += filters.value.queue_number.toString().toLowerCase() + ' ';
|
||||
|
||||
// Vuetify v-data-table memiliki fitur pencarian bawaan (di properti `search`).
|
||||
// Kita gunakan fitur ini untuk pencarian berbasis teks (No. RM, QR, Antrean).
|
||||
currentSearchTerm.value = textSearch.trim();
|
||||
|
||||
// 2. Filter berdasarkan Layanan (Service), yang tidak didukung oleh `v-data-table` search
|
||||
let results = ALL_PATIENTS_DATA;
|
||||
|
||||
if (filters.value.service) {
|
||||
results = results.filter(p => p.service === filters.value.service);
|
||||
}
|
||||
|
||||
// Terapkan hasil filter Layanan
|
||||
filteredPatientList.value = results;
|
||||
|
||||
// Reset status tab (penting agar hasil pencarian tampil terlepas dari tab yang aktif)
|
||||
activeTab.value = 'all';
|
||||
};
|
||||
|
||||
// 2. Logic Filter Status (saat tab ditekan)
|
||||
const filterByStatus = (status) => {
|
||||
// Pastikan status tab diperbarui
|
||||
activeTab.value = status;
|
||||
|
||||
// Clear the text search term when switching tabs
|
||||
currentSearchTerm.value = '';
|
||||
|
||||
if (status === 'all') {
|
||||
filteredPatientList.value = ALL_PATIENTS_DATA;
|
||||
} else {
|
||||
filteredPatientList.value = ALL_PATIENTS_DATA.filter(p => p.status === status);
|
||||
}
|
||||
|
||||
// Clear form filters (opsional, tetapi membuat UX lebih jelas)
|
||||
filters.value.rm_number = '';
|
||||
filters.value.qr_code = '';
|
||||
filters.value.queue_number = '';
|
||||
filters.value.service = null;
|
||||
};
|
||||
|
||||
// === COMPUTED PROPERTIES ===
|
||||
|
||||
// Hitungan untuk Badge Tab
|
||||
const listForCounts = computed(() => {
|
||||
// Gunakan ALL_PATIENTS_DATA untuk hitungan badge agar hitungan selalu stabil
|
||||
return ALL_PATIENTS_DATA;
|
||||
});
|
||||
|
||||
const totalCount = computed(() => listForCounts.value.length);
|
||||
const waitingCount = computed(() => listForCounts.value.filter(p => p.status === 'Menunggu Poli').length);
|
||||
const examiningCount = computed(() => listForCounts.value.filter(p => p.status === 'Diperiksa Dokter').length);
|
||||
const doneCount = computed(() => listForCounts.value.filter(p => p.status === 'Selesai Pelayanan').length);
|
||||
|
||||
// Data yang ditampilkan di tabel adalah data yang sudah difilter
|
||||
const paginatedPatients = computed(() => {
|
||||
// Jika ada filter status yang aktif (setelah menekan tab)
|
||||
if (activeTab.value !== 'all') {
|
||||
return filteredPatientList.value.filter(p => p.status === activeTab.value);
|
||||
}
|
||||
|
||||
// Jika tidak ada filter status yang aktif, tampilkan hasil dari applyFilters()
|
||||
return filteredPatientList.value;
|
||||
});
|
||||
|
||||
// === UTILITIES & NAVIGATION ===
|
||||
|
||||
// Utility function to set color based on status
|
||||
const getStatusColor = (status) => {
|
||||
if (status === 'Diperiksa Dokter') return 'green-darken-1';
|
||||
if (status === 'Menunggu Poli') return 'orange-darken-1';
|
||||
if (status === 'Selesai Pelayanan') return 'blue-darken-2';
|
||||
return 'grey';
|
||||
};
|
||||
|
||||
// Function to navigate to the detailed patient information page
|
||||
const viewPatient = (patientId) => {
|
||||
router.push(`/MonitoringPasien/Pasien/${patientId}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-toolbar {
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
|
||||
height: 64px !important;
|
||||
}
|
||||
|
||||
/* Jarak PENTING: Tambahkan padding horizontal ke toolbar */
|
||||
.floating-toolbar.custom-padding {
|
||||
padding: 0 16px; /* Memberi jarak 16px di kiri dan kanan */
|
||||
}
|
||||
|
||||
/* Penyesuaian agar teks dan ikon kontras dengan soft blue background */
|
||||
.v-toolbar-title {
|
||||
margin-left: 0 !important; /* Reset default margin */
|
||||
}
|
||||
|
||||
/* Pastikan chip Admin tidak mepet */
|
||||
.admin-chip {
|
||||
margin-right: 0 !important; /* Reset default margin */
|
||||
}
|
||||
|
||||
.v-tab.v-tab--selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-0">
|
||||
|
||||
<div class="pa-4 pb-0">
|
||||
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding">
|
||||
<v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
|
||||
<v-icon left class="mr-2" color="blue-darken-3">mdi-account-details</v-icon>
|
||||
Informasi Pasien
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="orange-darken-1"
|
||||
dark
|
||||
variant="flat"
|
||||
size="small"
|
||||
@click="$router.go(-1)"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
class="admin-chip"
|
||||
>
|
||||
Kembali
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</div>
|
||||
|
||||
<v-container class="mt-6">
|
||||
|
||||
<v-card class="mb-6 pa-6 elevation-4 rounded-lg" color="white">
|
||||
<v-row class="align-center mb-4">
|
||||
<v-col cols="12" md="8">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="36" color="blue-darken-2" class="mr-3">mdi-account-circle</v-icon>
|
||||
<div>
|
||||
<div class="text-caption text-uppercase text-grey-darken-1">Nama Pasien</div>
|
||||
<div class="text-h5 font-weight-black text-blue-darken-4">{{ patient.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="24" color="grey-darken-1" class="mr-3">mdi-numeric</v-icon>
|
||||
<div>
|
||||
<div class="text-caption text-uppercase text-grey-darken-1">Nomor Rekam Medis</div>
|
||||
<v-chip color="orange-darken-1" size="large" class="font-weight-bold">{{ patient.rm_number }}</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="d-flex justify-md-end">
|
||||
<v-chip :color="patient.status === 'Aktif' ? 'green-darken-1' : 'grey-darken-1'" size="x-large" class="font-weight-bold px-6 py-2">
|
||||
<v-icon start>mdi-progress-check</v-icon>
|
||||
Status: {{ patient.status }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" class="py-1">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-3" color="light-blue-darken-2">mdi-calendar</v-icon>
|
||||
<div>
|
||||
<div class="text-caption text-uppercase text-grey-darken-1">Tanggal Lahir</div>
|
||||
<div class="font-weight-medium text-subtitle-1">{{ patient.dob }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" class="py-1">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-3" color="light-blue-darken-2">mdi-phone</v-icon>
|
||||
<div>
|
||||
<div class="text-caption text-uppercase text-grey-darken-1">No Telepon</div>
|
||||
<div class="font-weight-medium text-subtitle-1">{{ patient.phone }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="12" md="4" class="py-1">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-3" color="light-blue-darken-2">mdi-map-marker</v-icon>
|
||||
<div>
|
||||
<div class="text-caption text-uppercase text-grey-darken-1">Alamat</div>
|
||||
<div class="font-weight-medium text-subtitle-1">{{ patient.address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
|
||||
<h2 class="text-h5 mb-4 font-weight-bold text-blue-darken-3">Tiket Pelayanan Aktif</h2>
|
||||
|
||||
<v-row class="ticket-row">
|
||||
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
v-for="ticket in patient.activeTickets"
|
||||
:key="ticket.title"
|
||||
>
|
||||
<MonitorPasienTicketCard
|
||||
:title="ticket.title"
|
||||
:color="ticket.color"
|
||||
:steps="ticket.steps"
|
||||
:current-step-label="ticket.currentStepLabel"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="!patient.activeTickets || patient.activeTickets.length === 0" cols="12">
|
||||
<v-card class="pa-6 text-center" variant="tonal" color="grey-lighten-2">
|
||||
<v-icon size="48" color="grey">mdi-ticket-off-outline</v-icon>
|
||||
<div class="text-subtitle-1 text-grey-darken-1 mt-2">Pasien ini tidak memiliki tiket pelayanan aktif saat ini.</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
// ===============================================
|
||||
// JSON MOCK DATA STRUCTURE (Tetap sama)
|
||||
// ===============================================
|
||||
const mockPatientData = [
|
||||
{
|
||||
id: '11111111',
|
||||
name: 'Ragil Bayu N. Wibowo',
|
||||
rm_number: '11111111',
|
||||
dob: '1 Juni 1967',
|
||||
phone: '0851-4563-2145',
|
||||
address: 'Jalan Sumpah Pemuda No. 12, Jakarta',
|
||||
status: 'Aktif',
|
||||
activeTickets: [
|
||||
// 1. TIKET JIWA
|
||||
{
|
||||
title: 'JIWA (Antrean: 1)',
|
||||
color: 'orange-darken-1',
|
||||
currentStepLabel: 'Klinik Jiwa',
|
||||
steps: [
|
||||
{ label: 'Daftar', date: '15-8-2023', time: '15.01' },
|
||||
{ label: 'Check in', date: '15-8-2023', time: '07.58' },
|
||||
{ label: 'Loket 1/3', date: '15-8-2023', time: '08.51' },
|
||||
{ label: 'Klinik Jiwa', date: '15-8-2023', time: '09.45' },
|
||||
{ label: 'Apotek', date: '-', time: '-' },
|
||||
{ label: 'Selesai', date: '-', time: '-' },
|
||||
]
|
||||
},
|
||||
// 2. TIKET RADIOLOGI
|
||||
{
|
||||
title: 'RADIOLOGI (Antrean: 5)',
|
||||
color: 'blue-darken-2',
|
||||
currentStepLabel: 'Loket 5/6/7',
|
||||
steps: [
|
||||
{ label: 'Daftar', date: '15-8-2023', time: '15.01' },
|
||||
{ label: 'Check in', date: '15-8-2023', time: '07.58' },
|
||||
{ label: 'Loket 5/6/7', date: '15-8-2023', time: '08.51' },
|
||||
{ label: 'Tunggu Radiologi', date: '-', time: '-' },
|
||||
{ label: 'Selesai', date: '-', time: '-' },
|
||||
]
|
||||
},
|
||||
// 3. TIKET GIZI
|
||||
{
|
||||
title: 'GIZI (Antrean: 12)',
|
||||
color: 'purple-darken-1',
|
||||
currentStepLabel: 'Konsultasi Gizi',
|
||||
steps: [
|
||||
{ label: 'Daftar', date: '16-8-2023', time: '07.00' },
|
||||
{ label: 'Tunggu Panggilan', date: '16-8-2023', time: '07.20' },
|
||||
{ label: 'Konsultasi Gizi', date: '16-8-2023', time: '07.45' },
|
||||
{ label: 'Selesai', date: '-', time: '-' },
|
||||
]
|
||||
},
|
||||
// 4. TIKET FISIOTERAPI
|
||||
{
|
||||
title: 'Fisioterapi (Antrean: 2)',
|
||||
color: 'teal-darken-1',
|
||||
currentStepLabel: 'Daftar',
|
||||
steps: [
|
||||
{ label: 'Daftar', date: '16-8-2023', time: '10.00' },
|
||||
{ label: 'Fisioterapi', date: '-', time: '-' },
|
||||
{ label: 'Selesai', date: '-', time: '-' },
|
||||
]
|
||||
},
|
||||
// 5. TIKET LABORATORIUM
|
||||
{
|
||||
title: 'LABORATORIUM (Antrean: 7)',
|
||||
color: 'red-darken-2',
|
||||
currentStepLabel: 'Pemeriksaan Sampel',
|
||||
steps: [
|
||||
{ label: 'Daftar', date: '17-8-2023', time: '06.30' },
|
||||
{ label: 'Administrasi', date: '17-8-2023', time: '06.40' },
|
||||
{ label: 'Tunggu Panggilan', date: '17-8-2023', time: '06.50' },
|
||||
{ label: 'Pengambilan Sampel', date: '17-8-2023', time: '07.10' },
|
||||
{ label: 'Pemeriksaan Sampel', date: '17-8-2023', time: '07.30' },
|
||||
{ label: 'Input Hasil', date: '-', time: '-' },
|
||||
{ label: 'Verifikasi Dokter', date: '-', time: '-' },
|
||||
{ label: 'Ambil Hasil', date: '-', time: '-' },
|
||||
{ label: 'Selesai', date: '-', time: '-' },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '33333333',
|
||||
name: 'Joko Susilo',
|
||||
rm_number: '33333333',
|
||||
dob: '20 Desember 1995',
|
||||
phone: '0878-1122-3344',
|
||||
address: 'Jl. Pahlawan No. 5, Surabaya',
|
||||
status: 'Tidak Aktif',
|
||||
activeTickets: []
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// ===============================================
|
||||
// MAIN SCRIPT LOGIC
|
||||
// ===============================================
|
||||
const route = useRoute();
|
||||
const patientId = route.params.id;
|
||||
|
||||
const patient = ref({
|
||||
name: 'Memuat...',
|
||||
address: 'Memuat...',
|
||||
phone: 'Memuat...',
|
||||
rm_number: patientId,
|
||||
dob: 'Memuat...',
|
||||
status: 'Memuat',
|
||||
activeTickets: []
|
||||
});
|
||||
|
||||
const fetchPatientData = () => {
|
||||
const data = mockPatientData.find(p => p.id === patientId);
|
||||
|
||||
if (data) {
|
||||
patient.value = data;
|
||||
} else {
|
||||
patient.value = {
|
||||
name: 'Data Pasien Tidak Ditemukan',
|
||||
address: '-',
|
||||
phone: '-',
|
||||
rm_number: patientId,
|
||||
dob: '-',
|
||||
status: 'Error',
|
||||
activeTickets: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchPatientData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =============================================== */
|
||||
/* FLOATING TOOLBAR STYLES */
|
||||
/* =============================================== */
|
||||
.floating-toolbar {
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
|
||||
height: 64px !important;
|
||||
}
|
||||
|
||||
.floating-toolbar.custom-padding {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.admin-chip {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* TICKET CARD ALIGNMENT STYLES (Fix Tinggi Card) */
|
||||
/* =============================================== */
|
||||
.ticket-row {
|
||||
/* Mengaktifkan flex container pada row */
|
||||
display: flex;
|
||||
/* Agar item (v-col) menyesuaikan tinggi item tertinggi */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Pastikan v-col children juga menggunakan full height */
|
||||
.ticket-row > .v-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* TIMELINE STYLES (Jika ada) */
|
||||
/* =============================================== */
|
||||
.v-timeline-item {
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,786 @@
|
||||
<!-- pages/Profile/Profil.vue -->
|
||||
<template>
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- Hero Header Section -->
|
||||
<div class="hero-header">
|
||||
<v-container class="py-8">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="white" size="40" class="mr-3">mdi-account-circle</v-icon>
|
||||
<div>
|
||||
<h1 class="text-h4 font-weight-bold text-white mb-1">Profil Saya</h1>
|
||||
<p class="text-body-1 text-white opacity-90">Kelola informasi profil dan pengaturan akun Anda</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</div>
|
||||
|
||||
<v-container class="content-container pb-12">
|
||||
<v-row>
|
||||
<!-- Left Sidebar - Profile Card -->
|
||||
<v-col cols="12" lg="4">
|
||||
<v-card class="rounded-xl elevation-8 sticky-card profile-card">
|
||||
<div class="text-center profile-content">
|
||||
<div class="profile-avatar-container">
|
||||
<v-avatar size="140" class="profile-avatar elevation-8">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
color="orange-darken-2"
|
||||
class="avatar-edit-btn elevation-4"
|
||||
@click="openPhotoDialog"
|
||||
>
|
||||
<v-icon size="18">mdi-camera</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<h2 class="text-h5 font-weight-bold mb-2">
|
||||
{{ user?.name || user?.preferred_username || 'User' }}
|
||||
</h2>
|
||||
<p class="text-body-2 text-grey-darken-1 mb-1">
|
||||
@{{ user?.preferred_username || profileData.username }}
|
||||
</p>
|
||||
<p class="text-body-2 text-grey mb-4">
|
||||
{{ user?.email || 'No email' }}
|
||||
</p>
|
||||
|
||||
<v-chip
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="small"
|
||||
prepend-icon="mdi-check-circle"
|
||||
class="mb-4"
|
||||
>
|
||||
Akun Terverifikasi
|
||||
</v-chip>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<!-- Quick Info -->
|
||||
<div class="text-left px-6">
|
||||
<div class="info-item mb-3">
|
||||
<v-icon size="20" color="blue-darken-2" class="mr-2">mdi-identifier</v-icon>
|
||||
<span class="text-body-2 text-grey-darken-1">
|
||||
ID: {{ user?.id ? user.id.substring(0, 12) : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item mb-3">
|
||||
<v-icon size="20" color="orange-darken-2" class="mr-2">mdi-calendar-check</v-icon>
|
||||
<span class="text-body-2 text-grey-darken-1">
|
||||
Bergabung: {{ profileData.joinDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<v-icon size="20" color="green-darken-2" class="mr-2">mdi-clock-outline</v-icon>
|
||||
<span class="text-body-2 text-grey-darken-1">
|
||||
Login: {{ profileData.lastLogin }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn
|
||||
color="blue-darken-2"
|
||||
variant="outlined"
|
||||
block
|
||||
class="rounded-lg"
|
||||
prepend-icon="mdi-cog"
|
||||
@click="navigateTo('/Profile/Pengaturan')"
|
||||
>
|
||||
Pengaturan Akun
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Content - Profile Details -->
|
||||
<v-col cols="12" lg="8">
|
||||
<!-- Personal Information -->
|
||||
<v-card class="rounded-xl elevation-4 mb-4">
|
||||
<v-card-title class="d-flex align-center pa-6 pb-4">
|
||||
<v-icon color="blue-darken-2" class="mr-2">mdi-account-details</v-icon>
|
||||
<span class="font-weight-bold">Informasi Pribadi</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
v-if="!isEditing"
|
||||
color="orange-darken-2"
|
||||
variant="flat"
|
||||
size="small"
|
||||
class="rounded-lg"
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="startEdit"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form ref="profileForm">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">NAMA LENGKAP</label>
|
||||
<v-text-field
|
||||
v-model="profileData.name"
|
||||
placeholder="Masukkan nama lengkap"
|
||||
prepend-inner-icon="mdi-account"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="blue-darken-2"
|
||||
:readonly="!isEditing"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">USERNAME</label>
|
||||
<v-text-field
|
||||
v-model="profileData.username"
|
||||
placeholder="Masukkan username"
|
||||
prepend-inner-icon="mdi-at"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="blue-darken-2"
|
||||
:readonly="!isEditing"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">EMAIL</label>
|
||||
<v-text-field
|
||||
v-model="profileData.email"
|
||||
placeholder="Masukkan email"
|
||||
prepend-inner-icon="mdi-email"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="blue-darken-2"
|
||||
:readonly="!isEditing"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider v-if="isEditing"></v-divider>
|
||||
<v-card-actions v-if="isEditing" class="pa-6 pt-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
class="rounded-lg px-6"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="blue-darken-2"
|
||||
variant="flat"
|
||||
class="rounded-lg px-8"
|
||||
prepend-icon="mdi-check"
|
||||
@click="saveProfile"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Simpan Perubahan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Security Section -->
|
||||
<v-card class="rounded-xl elevation-4 mb-4">
|
||||
<v-card-title class="d-flex align-center pa-6 pb-4">
|
||||
<v-icon color="orange-darken-2" class="mr-2">mdi-shield-check</v-icon>
|
||||
<span class="font-weight-bold">Keamanan</span>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-list class="transparent">
|
||||
<v-list-item
|
||||
class="rounded-lg mb-2 px-4"
|
||||
prepend-icon="mdi-lock-reset"
|
||||
@click="openPasswordDialog"
|
||||
>
|
||||
<v-list-item-title class="font-weight-medium">Ubah Password</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">Perbarui password akun Anda</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-icon color="grey">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<v-dialog v-model="passwordDialog" max-width="500" persistent>
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="pa-6 pb-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="orange-darken-2" class="mr-2">mdi-lock-reset</v-icon>
|
||||
<span class="font-weight-bold">Ubah Password</span>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form ref="passwordForm">
|
||||
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">PASSWORD SAAT INI</label>
|
||||
<v-text-field
|
||||
v-model="passwordData.current"
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="blue-darken-2"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">PASSWORD BARU</label>
|
||||
<v-text-field
|
||||
v-model="passwordData.new"
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-lock-plus"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="blue-darken-2"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">KONFIRMASI PASSWORD</label>
|
||||
<v-text-field
|
||||
v-model="passwordData.confirm"
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-lock-check"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="blue-darken-2"
|
||||
hide-details="auto"
|
||||
></v-text-field>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 pt-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
class="rounded-lg"
|
||||
@click="passwordDialog = false"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-darken-2"
|
||||
variant="flat"
|
||||
class="rounded-lg px-6"
|
||||
@click="changePassword"
|
||||
:loading="isChangingPassword"
|
||||
>
|
||||
Ubah Password
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Active Devices Dialog -->
|
||||
<v-dialog v-model="devicesDialog" max-width="700" scrollable>
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="pa-6 pb-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="blue-darken-2" class="mr-2">mdi-devices</v-icon>
|
||||
<span class="font-weight-bold">Perangkat Aktif</span>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-0" style="max-height: 500px;">
|
||||
<v-list class="py-0">
|
||||
<v-list-item
|
||||
v-for="(session, index) in activeSessions"
|
||||
:key="index"
|
||||
class="py-4 px-6"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :color="session.current ? 'success' : 'blue-grey-lighten-4'" size="48">
|
||||
<v-icon :color="session.current ? 'white' : 'blue-grey-darken-2'">
|
||||
{{ session.icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-bold mb-1">
|
||||
{{ session.device }}
|
||||
<v-chip v-if="session.current" size="x-small" color="success" class="ml-2">
|
||||
Sesi Ini
|
||||
</v-chip>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
<div>{{ session.location }}</div>
|
||||
<div class="text-grey-darken-1 mt-1">
|
||||
<v-icon size="12">mdi-clock-outline</v-icon>
|
||||
{{ session.lastActive }}
|
||||
</div>
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append v-if="!session.current">
|
||||
<v-btn
|
||||
icon="mdi-close-circle"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="removeSession(index)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 pt-4">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
class="rounded-lg"
|
||||
prepend-icon="mdi-logout-variant"
|
||||
@click="logoutAllDevices"
|
||||
>
|
||||
Keluar dari Semua Perangkat
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="text"
|
||||
class="rounded-lg"
|
||||
@click="devicesDialog = false"
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Photo Upload Dialog -->
|
||||
<v-dialog v-model="photoDialog" max-width="400">
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="pa-6 pb-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="blue-darken-2" class="mr-2">mdi-camera</v-icon>
|
||||
<span class="font-weight-bold">Ubah Foto Profil</span>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-6 text-center">
|
||||
<v-avatar size="150" class="mb-4">
|
||||
<v-img :src="user?.picture || 'https://i.pravatar.cc/300?img=68'"></v-img>
|
||||
</v-avatar>
|
||||
<v-file-input
|
||||
label="Pilih foto baru"
|
||||
variant="outlined"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="mdi-image"
|
||||
accept="image/*"
|
||||
hide-details
|
||||
></v-file-input>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 pt-4">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
class="rounded-lg"
|
||||
>
|
||||
Hapus Foto
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="grey-darken-1"
|
||||
class="rounded-lg"
|
||||
@click="photoDialog = false"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="blue-darken-2"
|
||||
variant="flat"
|
||||
class="rounded-lg px-6"
|
||||
>
|
||||
Simpan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Success Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
location="top"
|
||||
timeout="3000"
|
||||
class="snackbar-custom"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-2">{{ snackbarIcon }}</v-icon>
|
||||
<span>{{ snackbarMessage }}</span>
|
||||
</div>
|
||||
<template v-slot:actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
icon="mdi-close"
|
||||
@click="snackbar = false"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue';
|
||||
import { navigateTo } from '#app';
|
||||
// Explicitly import useAuth composable
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
});
|
||||
|
||||
// Get user data from your custom useAuth composable
|
||||
const { user, isLoading: authLoading, checkAuth } = useAuth();
|
||||
|
||||
const isEditing = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isChangingPassword = ref(false);
|
||||
const snackbar = ref(false);
|
||||
const snackbarMessage = ref('');
|
||||
const snackbarColor = ref('success');
|
||||
|
||||
const passwordDialog = ref(false);
|
||||
const devicesDialog = ref(false);
|
||||
const photoDialog = ref(false);
|
||||
|
||||
const twoFactorEnabled = ref(false);
|
||||
const emailNotifications = ref(true);
|
||||
const darkMode = ref(false);
|
||||
|
||||
const snackbarIcon = computed(() => {
|
||||
return snackbarColor.value === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle';
|
||||
});
|
||||
|
||||
// Initialize profile data with user info
|
||||
const profileData = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
bio: '',
|
||||
picture: '',
|
||||
joinDate: '15 Januari 2024',
|
||||
lastLogin: 'Memuat...' // Will be updated from database
|
||||
});
|
||||
|
||||
const passwordData = reactive({
|
||||
current: '',
|
||||
new: '',
|
||||
confirm: ''
|
||||
});
|
||||
|
||||
const activeSessions = ref([
|
||||
{
|
||||
device: 'Chrome on Windows 11',
|
||||
location: 'Sidoarjo, Indonesia',
|
||||
lastActive: 'Sekarang',
|
||||
icon: 'mdi-laptop',
|
||||
current: true
|
||||
},
|
||||
{
|
||||
device: 'Mobile App on Android',
|
||||
location: 'Surabaya, Indonesia',
|
||||
lastActive: '2 jam yang lalu',
|
||||
icon: 'mdi-cellphone',
|
||||
current: false
|
||||
},
|
||||
{
|
||||
device: 'Safari on macOS',
|
||||
location: 'Jakarta, Indonesia',
|
||||
lastActive: '1 hari yang lalu',
|
||||
icon: 'mdi-laptop',
|
||||
current: false
|
||||
}
|
||||
]);
|
||||
|
||||
const originalData = ref({});
|
||||
|
||||
// Format last login time
|
||||
const formatLastLogin = (timestamp) => {
|
||||
if (!timestamp) return 'Belum pernah login';
|
||||
|
||||
// Convert Unix timestamp (seconds) to Date object
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
// Format like: "10 Desember 2025 pukul 13.00"
|
||||
return date.toLocaleString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).replace(',', ' pukul');
|
||||
};
|
||||
|
||||
// Fetch user data from database
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const userData = await $fetch('/api/users/current');
|
||||
if (userData) {
|
||||
// Get lastLogin from database
|
||||
const users = await $fetch('/api/users/list');
|
||||
const dbUser = users.find(u => u.id === userData.id);
|
||||
|
||||
if (dbUser && dbUser.lastLogin) {
|
||||
profileData.lastLogin = formatLastLogin(dbUser.lastLogin);
|
||||
} else {
|
||||
// Fallback to current time if not found
|
||||
profileData.lastLogin = formatLastLogin(Math.floor(Date.now() / 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user data:', error);
|
||||
// Fallback to current time on error
|
||||
profileData.lastLogin = formatLastLogin(Math.floor(Date.now() / 1000));
|
||||
}
|
||||
};
|
||||
|
||||
// Sync profile data with user data
|
||||
const syncUserData = () => {
|
||||
if (user.value) {
|
||||
profileData.id = user.value.id || '';
|
||||
profileData.name = user.value.name || user.value.preferred_username || '';
|
||||
profileData.username = user.value.preferred_username || user.value.email?.split('@')[0] || '';
|
||||
profileData.email = user.value.email || '';
|
||||
profileData.picture = user.value.picture || 'https://i.pravatar.cc/300?img=68';
|
||||
// phone and bio can be loaded from additional user data if available
|
||||
profileData.phone = user.value.phone_number || '';
|
||||
profileData.bio = user.value.bio || '';
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for user changes and sync
|
||||
watch(user, () => {
|
||||
syncUserData();
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(async () => {
|
||||
// Check authentication status on mount
|
||||
await checkAuth();
|
||||
syncUserData();
|
||||
// Fetch lastLogin from database
|
||||
await fetchUserData();
|
||||
originalData.value = { ...profileData };
|
||||
});
|
||||
|
||||
const startEdit = () => {
|
||||
originalData.value = { ...profileData };
|
||||
isEditing.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
Object.assign(profileData, originalData.value);
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// TODO: Replace with actual API call to update user profile
|
||||
// await $fetch('/api/profile/update', {
|
||||
// method: 'PUT',
|
||||
// body: {
|
||||
// name: profileData.name,
|
||||
// username: profileData.username,
|
||||
// email: profileData.email,
|
||||
// phone: profileData.phone,
|
||||
// bio: profileData.bio
|
||||
// }
|
||||
// });
|
||||
|
||||
originalData.value = { ...profileData };
|
||||
isEditing.value = false;
|
||||
snackbarMessage.value = 'Profil berhasil diperbarui!';
|
||||
snackbarColor.value = 'success';
|
||||
snackbar.value = true;
|
||||
} catch (error) {
|
||||
snackbarMessage.value = 'Gagal memperbarui profil!';
|
||||
snackbarColor.value = 'error';
|
||||
snackbar.value = true;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openPhotoDialog = () => {
|
||||
photoDialog.value = true;
|
||||
};
|
||||
|
||||
const openPasswordDialog = () => {
|
||||
passwordDialog.value = true;
|
||||
passwordData.current = '';
|
||||
passwordData.new = '';
|
||||
passwordData.confirm = '';
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (passwordData.new !== passwordData.confirm) {
|
||||
snackbarMessage.value = 'Password baru tidak cocok!';
|
||||
snackbarColor.value = 'error';
|
||||
snackbar.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isChangingPassword.value = true;
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// TODO: Replace with actual API call
|
||||
|
||||
passwordDialog.value = false;
|
||||
snackbarMessage.value = 'Password berhasil diubah!';
|
||||
snackbarColor.value = 'success';
|
||||
snackbar.value = true;
|
||||
} catch (error) {
|
||||
snackbarMessage.value = 'Gagal mengubah password!';
|
||||
snackbarColor.value = 'error';
|
||||
snackbar.value = true;
|
||||
} finally {
|
||||
isChangingPassword.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openDevicesDialog = () => {
|
||||
devicesDialog.value = true;
|
||||
};
|
||||
|
||||
const removeSession = (index) => {
|
||||
activeSessions.value.splice(index, 1);
|
||||
snackbarMessage.value = 'Perangkat berhasil dihapus dari sesi aktif';
|
||||
snackbarColor.value = 'success';
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const logoutAllDevices = () => {
|
||||
// Keep only current session
|
||||
activeSessions.value = activeSessions.value.filter(s => s.current);
|
||||
devicesDialog.value = false;
|
||||
snackbarMessage.value = 'Berhasil keluar dari semua perangkat lain';
|
||||
snackbarColor.value = 'success';
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const toggleTwoFactor = () => {
|
||||
const status = twoFactorEnabled.value ? 'diaktifkan' : 'dinonaktifkan';
|
||||
snackbarMessage.value = `Autentikasi dua faktor ${status}`;
|
||||
snackbarColor.value = 'info';
|
||||
snackbar.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 50%, #f57c00 100%);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.sticky-card {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
padding-top: 0;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-top: -70px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
border: 6px solid white;
|
||||
background: white;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.avatar-edit-btn {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.v-list-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.snackbar-custom {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.sticky-card {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<v-main class="ranap-admin-page">
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- Modern Header -->
|
||||
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
|
||||
<v-icon size="48" color="white" class="mr-4">mdi-hospital-box-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Ranap Admin</h1>
|
||||
</v-card>
|
||||
|
||||
<v-card class="elevation-2 rounded-xl pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-caption mr-2">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="solo"
|
||||
flat
|
||||
class="pagination-select"
|
||||
:menu-props="{ attach: true }"
|
||||
></v-select>
|
||||
<span class="text-caption ml-2">entries</span>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search"
|
||||
append-inner-icon="mdi-magnify"
|
||||
single-line
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
class="search-field"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-0"
|
||||
item-key="noAntrean"
|
||||
>
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<v-btn small color="success" class="text-white" @click="selectItem(item)">
|
||||
Selesai
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:bottom>
|
||||
<div class="d-flex justify-space-between align-center pa-2">
|
||||
<span class="text-caption">Showing {{ (page - 1) * itemsPerPage + 1 }} to {{ Math.min(page * itemsPerPage, filteredItems.length) }} of {{ filteredItems.length }} entries</span>
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
:total-visible="5"
|
||||
rounded="circle"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-row class="mt-6">
|
||||
<v-col cols="12" md="4" class="pr-md-2">
|
||||
<v-card class="elevation-2 rounded-xl next-card pa-4 d-flex flex-column align-center text-center">
|
||||
<h2 class="text-h3 font-weight-bold next-title">NEXT</h2>
|
||||
<span class="text-h4 font-weight-medium my-4 next-content">{{ nextAntrean || 'Kosong' }}</span>
|
||||
<p class="text-caption">Klik untuk memanggil pasien selanjutnya</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
const headers = [
|
||||
{ title: 'No', align: 'start', sortable: false, key: 'no' },
|
||||
{ title: 'No. Antrean', key: 'noAntrean' },
|
||||
{ title: 'Daftar', key: 'daftar' },
|
||||
{ title: 'Pelayanan', key: 'pelayanan' },
|
||||
{ title: 'Aksi', key: 'aksi' },
|
||||
];
|
||||
|
||||
const items = ref([
|
||||
{ no: 1, noAntrean: '001', daftar: '26 Aug 2025 07:10:31', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 2, noAntrean: '002', daftar: '26 Aug 2025 07:10:35', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 3, noAntrean: '003', daftar: '26 Aug 2025 07:10:44', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 4, noAntrean: '004', daftar: '26 Aug 2025 07:10:46', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 5, noAntrean: '005', daftar: '26 Aug 2025 07:10:47', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 6, noAntrean: '006', daftar: '26 Aug 2025 07:10:49', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 7, noAntrean: '007', daftar: '26 Aug 2025 07:10:51', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 8, noAntrean: '008', daftar: '26 Aug 2025 07:10:53', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 9, noAntrean: '009', daftar: '26 Aug 2025 07:10:54', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 10, noAntrean: '010', daftar: '26 Aug 2025 07:10:55', pelayanan: 'Belum Dilayani' },
|
||||
]);
|
||||
|
||||
const search = ref('');
|
||||
const itemsPerPage = ref(10);
|
||||
const page = ref(1);
|
||||
|
||||
const nextAntrean = ref('001');
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!search.value) {
|
||||
return items.value;
|
||||
}
|
||||
return items.value.filter(item =>
|
||||
Object.values(item).some(val =>
|
||||
String(val).toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const selectItem = (item) => {
|
||||
console.log('Item selected:', item);
|
||||
// Di sini Anda bisa menambahkan logika untuk mengubah status pasien menjadi "Dilayani" atau memindahkannya ke antrean berikutnya.
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ranap-admin-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header-banner {
|
||||
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
|
||||
color: white;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.search-field, .pagination-select {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.v-data-table :deep(table) {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 10px;
|
||||
}
|
||||
|
||||
.v-data-table :deep(tbody tr) {
|
||||
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.next-card {
|
||||
background-color: #00A896;
|
||||
color: white;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.next-title {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.next-content {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card>
|
||||
<!-- <v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Master Loket</span>
|
||||
<v-btn color=#ff9248 @click="tambahLoket" style="color:white;">Tambah Baru</v-btn>
|
||||
</v-card-title> -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Master Loket</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TabelData
|
||||
:headers="loketHeaders"
|
||||
:items="loketData"
|
||||
title="Master Loket"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<v-btn
|
||||
small
|
||||
color="#ff9248"
|
||||
@click="editLoket(item)"
|
||||
class="mr-2"
|
||||
style="color: white"
|
||||
>Edit</v-btn
|
||||
>
|
||||
<v-btn small color="grey-lighten-4" @click="deleteLoket(item)"
|
||||
>Delete</v-btn
|
||||
>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import TabelData from "../../components/TabelData.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loketHeaders = ref([
|
||||
{ title: "No", value: "no" },
|
||||
{ title: "Nama Loket", value: "namaLoket" },
|
||||
{ title: "Kuota", value: "kuota" },
|
||||
{ title: "Pelayanan", value: "pelayanan" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Keterangan", value: "keterangan" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false }, // 'value' harus 'actions'
|
||||
]);
|
||||
|
||||
// Master-Loket.vue
|
||||
const loketData = ref([
|
||||
{
|
||||
id: 1,
|
||||
no: 1,
|
||||
namaLoket: "Loket 1",
|
||||
kuota: 500,
|
||||
pelayanan: ["RADIOTERAPI", "REHAB MEDIK", "TINDAKAN"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
no: 2,
|
||||
namaLoket: "Loket 2",
|
||||
kuota: 666,
|
||||
pelayanan: ["JIWA", "SARAF"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
no: 3,
|
||||
namaLoket: "Loket 3",
|
||||
kuota: 666,
|
||||
pelayanan: ["ANESTESI", "JANTUNG"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
no: 4,
|
||||
namaLoket: "Loket 4",
|
||||
kuota: 3676,
|
||||
pelayanan: ["KULIT KELAMIN", "PARU"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
]);
|
||||
|
||||
const editLoket = (item) => {
|
||||
router.push({ path: `/Setting/Edit-Loket/${item.id}` });
|
||||
};
|
||||
|
||||
const deleteLoket = (item) => {
|
||||
const index = loketData.value.findIndex((loket) => loket.id === item.id);
|
||||
if (index !== -1) {
|
||||
loketData.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const tambahLoket = () => {
|
||||
router.push({ path: "/Setting/Tambah-Loket" });
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user