Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8ba8d638 | ||
|
|
af855cb6c8 | ||
|
|
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 | ||
|
|
a6b16e1cd1 | ||
|
|
3ad64b2223 | ||
|
|
1b1d7e331a | ||
|
|
a66f883372 | ||
|
|
28822718de | ||
|
|
bc5419ca4d | ||
|
|
d6ffc5ee9e | ||
|
|
3abb8de83a | ||
|
|
fc16b2bded | ||
|
|
89b36b7d1f | ||
|
|
15c49262f1 | ||
|
|
1f5b25a873 | ||
|
|
b1c1ff86f0 | ||
|
|
6c27987f53 |
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;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<v-app-bar app color="#ff9248" dark>
|
||||
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="ml-2 font-weight-bold">
|
||||
<span class="text-blue-darken-2">Antrean</span> RSSA
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- Show loading state or user info -->
|
||||
<div v-if="isLoading" class="d-flex align-center">
|
||||
<v-progress-circular indeterminate size="20" class="mr-2"></v-progress-circular>
|
||||
<span class="mr-2">Loading...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="isAuthenticated && user">
|
||||
<ProfilePopup
|
||||
:user="user"
|
||||
@logout="handleLogout"
|
||||
/><template>
|
||||
<v-app-bar app color="blue-grey-darken-3" dark flat>
|
||||
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="ml-2 font-weight-bold">
|
||||
<span class="text-orange-darken-2">Antrian</span> RSSA
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<div v-if="isLoading" class="d-flex align-center">
|
||||
<v-progress-circular indeterminate color="orange-darken-2" size="20" class="mr-2"></v-progress-circular>
|
||||
<span class="text-caption">Loading...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="isAuthenticated && user">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
color="transparent"
|
||||
class="pa-2 text-capitalize"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar color="orange-darken-2" size="36" class="mr-2">
|
||||
<span class="text-white font-weight-bold">{{ user.name?.charAt(0) || 'U' }}</span>
|
||||
</v-avatar>
|
||||
<span class="text-subtitle-1 font-weight-bold text-white">{{ user.name || 'User' }}</span>
|
||||
<v-icon right size="20" class="ml-1">mdi-chevron-down</v-icon>
|
||||
</div>
|
||||
</v-btn>
|
||||
</template>
|
||||
<ProfilePopup
|
||||
:user="user"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-btn @click="redirectToLogin" color="orange-darken-2" variant="flat" rounded="lg" class="text-capitalize">
|
||||
<v-icon left>mdi-login</v-icon>
|
||||
Login
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
<span class="mr-2">{{ user.name || user.preferred_username || user.email }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-btn @click="redirectToLogin" color="white" text>
|
||||
Login
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProfilePopup from './ProfilePopup.vue';
|
||||
|
||||
// Emit untuk parent component
|
||||
const emit = defineEmits(['toggle-rail']);
|
||||
|
||||
// Use auth composable
|
||||
const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
|
||||
|
||||
// Handle logout - use the composable's logout method
|
||||
const handleLogout = async () => {
|
||||
console.log("🚪 AppBar logout initiated...")
|
||||
try {
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error("❌ AppBar logout error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
const redirectToLogin = () => {
|
||||
navigateTo('/LoginPage')
|
||||
}
|
||||
|
||||
// Check authentication on mount
|
||||
onMounted(async () => {
|
||||
await checkAuth()
|
||||
})
|
||||
</script>
|
||||
@@ -1,63 +1,161 @@
|
||||
<template>
|
||||
<v-card class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||
Edit Hak Akses Menu | {{ localItem.namaTipeUser }}
|
||||
<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-card-text>
|
||||
<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="compact" class="elevation-1 rounded-lg">
|
||||
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">No</th>
|
||||
<th class="text-left">Menu</th>
|
||||
<th class="text-center">Akses</th>
|
||||
<th class="text-center">Lihat</th>
|
||||
<th class="text-center">Tambah</th>
|
||||
<th class="text-center">Edit</th>
|
||||
<th class="text-center">Hapus</th>
|
||||
<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>
|
||||
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ menu.name }}</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
<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-4">
|
||||
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
rounded="lg"
|
||||
class="text-capitalize mr-2"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-darken-2"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg"
|
||||
@click="$emit('save', localItem)"
|
||||
rounded="lg"
|
||||
class="text-capitalize"
|
||||
@click="handleSave"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
@@ -66,7 +164,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
|
||||
// Define types for better readability and safety
|
||||
interface HakAksesMenu {
|
||||
@@ -78,8 +177,25 @@ interface HakAksesMenu {
|
||||
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[];
|
||||
}
|
||||
@@ -89,10 +205,6 @@ const props = defineProps({
|
||||
item: {
|
||||
type: Object as () => HakAksesData,
|
||||
required: true,
|
||||
// Add custom validator for more robust checks
|
||||
validator: (value: HakAksesData) => {
|
||||
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,11 +213,214 @@ 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>
|
||||
@@ -121,4 +436,22 @@ watch(() => props.item, (newItem) => {
|
||||
.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>
|
||||
@@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
location="bottom right"
|
||||
origin="top right"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-avatar size="40">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="rounded-lg elevation-4 pa-4" width="300">
|
||||
<div class="d-flex align-center pb-2">
|
||||
<v-avatar size="48">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<div class="ml-4">
|
||||
<div class="text-subtitle-1 font-weight-bold">
|
||||
{{ user?.name || user?.preferred_username || 'User' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-1">
|
||||
{{ user?.email || 'No email' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-2">
|
||||
ID: {{ user?.id?.substring(0, 8) }}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('account')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Pengaturan Akun</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-weather-night</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Mode Gelap</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-switch
|
||||
v-model="darkMode"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
></v-switch>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('profile')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-account</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Profil Saya</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-2"></v-divider>
|
||||
|
||||
<v-list-item
|
||||
link
|
||||
class="rounded-lg text-red"
|
||||
@click="signOut"
|
||||
:disabled="isLoggingOut"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="red">mdi-logout</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const menu = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const isLoggingOut = ref(false);
|
||||
const emit = defineEmits(['logout']);
|
||||
|
||||
/**
|
||||
* Handles the logout action - delegates to parent
|
||||
*/
|
||||
const signOut = async () => {
|
||||
if (isLoggingOut.value) return;
|
||||
|
||||
isLoggingOut.value = true;
|
||||
menu.value = false;
|
||||
|
||||
try {
|
||||
console.log('🚪 ProfilePopup signOut called...')
|
||||
emit('logout');
|
||||
} finally {
|
||||
isLoggingOut.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
console.log('Action triggered:', action);
|
||||
|
||||
switch(action) {
|
||||
case 'account':
|
||||
// Navigate to account settings
|
||||
navigateTo('/settings/account')
|
||||
break;
|
||||
case 'profile':
|
||||
// Navigate to profile page
|
||||
navigateTo('/profile')
|
||||
break;
|
||||
case 'darkMode':
|
||||
// Dark mode toggle is handled by v-model
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown action:', action);
|
||||
}
|
||||
|
||||
// Close menu for navigation actions
|
||||
if (action !== 'darkMode') {
|
||||
menu.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-red {
|
||||
color: rgb(244, 67, 54) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,75 +0,0 @@
|
||||
<!-- <template>
|
||||
<v-dialog v-model="dialog" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
Atur Urutan Menu
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list dense>
|
||||
<draggable v-model="localMenus" item-key="title" @end="onDragEnd">
|
||||
<template #item="{ element }">
|
||||
<v-list-item class="reorder-item">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ element.title }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-drag</v-icon>
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</draggable>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey-darken-1" text @click="dialog = false">Batal</v-btn>
|
||||
<v-btn color="blue" text @click="saveOrder">Simpan Urutan</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import navigationItems from '~/data/menu.js';
|
||||
|
||||
const dialog = ref(false);
|
||||
const localMenus = ref([]);
|
||||
|
||||
// Watch for changes in the dialog's visibility
|
||||
watch(dialog, (val) => {
|
||||
if (val) {
|
||||
// Make a deep copy to avoid mutating the original data
|
||||
localMenus.value = JSON.parse(JSON.stringify(navigationItems));
|
||||
}
|
||||
});
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
// Logic to handle the end of a drag event
|
||||
};
|
||||
|
||||
const saveOrder = () => {
|
||||
// Emit an event with the new menu order
|
||||
// You would then handle this in the parent component
|
||||
// to update the ~/data/menu.js file (or your state management)
|
||||
// and trigger a UI refresh.
|
||||
dialog.value = false;
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
dialog.value = true;
|
||||
};
|
||||
|
||||
defineExpose({ openDialog });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reorder-item {
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.reorder-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style> -->
|
||||
@@ -1,103 +0,0 @@
|
||||
<!-- components/sideBar.vue -->
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
:model-value="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
app
|
||||
@update:model-value="emit('update:drawer', $event)"
|
||||
>
|
||||
<v-list density="compact" nav>
|
||||
<template v-for="item in items" :key="item.name">
|
||||
<v-menu
|
||||
v-if="item.children"
|
||||
open-on-hover
|
||||
:location="rail ? 'end' : undefined"
|
||||
:offset="10"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
:value="item.name"
|
||||
:to="!rail ? item.path : undefined"
|
||||
:link="!rail"
|
||||
></v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="py-2" min-width="200">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold px-4 py-2">
|
||||
{{ item.name }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.name"
|
||||
:to="child.path"
|
||||
:title="child.name"
|
||||
:prepend-icon="child.icon"
|
||||
link
|
||||
class="px-4"
|
||||
></v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-tooltip
|
||||
v-else
|
||||
:disabled="!rail"
|
||||
open-on-hover
|
||||
location="end"
|
||||
:text="item.name"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
:to="item.path"
|
||||
link
|
||||
></v-list-item>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
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']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-navigation-drawer__content {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
</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,347 @@
|
||||
<!-- components/TabelData.vue -->
|
||||
<template>
|
||||
<v-card-text>
|
||||
<!-- Title Section -->
|
||||
<v-row no-gutters class="mb-3" v-if="title">
|
||||
<v-col cols="12">
|
||||
<v-card-title
|
||||
class="text-subtitle-1 font-weight-bold pa-0"
|
||||
:class="getTitleClass(title)"
|
||||
>
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Controls Section -->
|
||||
<v-row no-gutters class="d-flex align-center mb-4">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center">
|
||||
<div class="d-flex align-center">
|
||||
<span>Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="mx-2"
|
||||
style="width: 80px;"
|
||||
/>
|
||||
<span>entries</span>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" class="d-flex justify-end align-center">
|
||||
<div v-if="showSearch" class="d-flex align-center">
|
||||
<span class="mr-2">Search:</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="paginatedItems"
|
||||
:search="search"
|
||||
no-data-text="No data available in table"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
item-value="no"
|
||||
>
|
||||
<!-- Custom slot untuk nomor urut -->
|
||||
<template v-slot:item.no="{ index }">
|
||||
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk jam panggil dengan highlighting -->
|
||||
<template v-slot:item.jamPanggil="{ item }">
|
||||
<slot name="item.jamPanggil" :item="item">
|
||||
<span>{{ item.jamPanggil }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk status -->
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
text-color="white"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk barcode dengan formatting -->
|
||||
<template v-slot:item.barcode="{ item }">
|
||||
<span class="font-mono">{{ item.barcode }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk no antrian dengan highlighting -->
|
||||
<template v-slot:item.noAntrian="{ item }">
|
||||
<div>
|
||||
<span class="font-weight-medium">{{ item.noAntrian.split(' |')[0] }}</span>
|
||||
<br>
|
||||
<small class="text-grey-darken-1">{{ item.noAntrian.split(' |')[1] }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk klinik dengan chip styling -->
|
||||
<template v-slot:item.klinik="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:color="getKlinikColor(item.klinik)"
|
||||
>
|
||||
{{ item.klinik }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk fast track -->
|
||||
<template v-slot:item.fastTrack="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.fastTrack }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk pembayaran -->
|
||||
<template v-slot:item.pembayaran="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.pembayaran }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk keterangan -->
|
||||
<template v-slot:item.keterangan="{ item }">
|
||||
<span v-if="item.keterangan" class="text-green font-weight-medium">
|
||||
{{ item.keterangan }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- Slot untuk aksi -->
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<slot name="actions" :item="item" />
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div class="text-center pa-4">No data available in table</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<!-- Footer Pagination -->
|
||||
<div class="d-flex justify-space-between align-center mt-4">
|
||||
<div class="text-body-2 text-grey-darken-1">
|
||||
Showing {{ currentPageStart }} to {{ currentPageEnd }} of {{ filteredTotal }} entries
|
||||
</div>
|
||||
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
headers: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const currentPage = ref(1);
|
||||
|
||||
// Filter items based on search
|
||||
const filteredItems = computed(() => {
|
||||
if (!search.value) {
|
||||
return props.items;
|
||||
}
|
||||
|
||||
const searchLower = search.value.toLowerCase();
|
||||
return props.items.filter(item => {
|
||||
return Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredTotal = computed(() => filteredItems.value.length);
|
||||
const totalPages = computed(() => Math.ceil(filteredTotal.value / itemsPerPage.value));
|
||||
|
||||
// Paginate the filtered items
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||
const end = start + itemsPerPage.value;
|
||||
return filteredItems.value.slice(start, end);
|
||||
});
|
||||
|
||||
const currentPageStart = computed(() => {
|
||||
if (filteredTotal.value === 0) return 0;
|
||||
return (currentPage.value - 1) * itemsPerPage.value + 1;
|
||||
});
|
||||
|
||||
const currentPageEnd = computed(() => {
|
||||
const end = currentPage.value * itemsPerPage.value;
|
||||
return Math.min(end, filteredTotal.value);
|
||||
});
|
||||
|
||||
// Method untuk mendapatkan warna status
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'Tunggu Daftar':
|
||||
return 'orange';
|
||||
case 'Barcode':
|
||||
return 'blue';
|
||||
case 'Selesai':
|
||||
return 'green';
|
||||
case 'Batal':
|
||||
return 'red';
|
||||
case 'Aktif':
|
||||
return 'success';
|
||||
case 'Menunggu':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
};
|
||||
|
||||
// Method untuk mendapatkan warna klinik
|
||||
const getKlinikColor = (klinik) => {
|
||||
switch (klinik) {
|
||||
case 'KANDUNGAN':
|
||||
return 'pink';
|
||||
case 'IPD':
|
||||
return 'blue';
|
||||
case 'THT':
|
||||
return 'orange';
|
||||
case 'SARAF':
|
||||
return 'purple';
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
};
|
||||
|
||||
// Method untuk mendapatkan class title
|
||||
const getTitleClass = (title) => {
|
||||
if (title.includes('TERLAMBAT')) {
|
||||
return 'text-warning';
|
||||
} else if (title.includes('PENDING')) {
|
||||
return 'text-info';
|
||||
} else if (title.includes('DI LOKET')) {
|
||||
return 'text-success';
|
||||
}
|
||||
return 'text-primary';
|
||||
};
|
||||
|
||||
// Watch untuk reset halaman ketika items per page berubah
|
||||
watch(itemsPerPage, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
// Watch untuk reset halaman ketika items berubah
|
||||
watch(() => props.items, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
// Watch untuk reset halaman ketika search berubah
|
||||
watch(search, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-red {
|
||||
color: #f44336 !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #ff9800 !important;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: #2196f3 !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #4caf50 !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #1976d2 !important;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Table enhancements */
|
||||
:deep(.v-data-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.v-data-table tbody tr) {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.v-data-table tbody tr:hover) {
|
||||
background: rgba(25, 118, 210, 0.04) !important;
|
||||
}
|
||||
|
||||
:deep(.v-data-table th) {
|
||||
font-weight: 600 !important;
|
||||
background: #fafafa !important;
|
||||
color: #424242 !important;
|
||||
}
|
||||
|
||||
:deep(.v-data-table td) {
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
:deep(.v-data-table) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.v-pagination {
|
||||
:deep(.v-pagination__item) {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.pilih="{ item }">
|
||||
<v-checkbox
|
||||
:model-value="isSelected(item.id)"
|
||||
@change="toggleService(item.id)"
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.no="{ item }">
|
||||
{{ item.no }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
headers: Array,
|
||||
items: Array,
|
||||
selectedItems: Array,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:selectedItems']);
|
||||
|
||||
const isSelected = (id) => props.selectedItems.includes(id);
|
||||
|
||||
const toggleService = (id) => {
|
||||
const newSelection = isSelected(id)
|
||||
? props.selectedItems.filter(serviceId => serviceId !== id)
|
||||
: [...props.selectedItems, id];
|
||||
|
||||
emit('update:selectedItems', newSelection);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-data-table {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-checkbox {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Filter Section -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ml-4">
|
||||
<div style="width: 200px;" class="mr-4">
|
||||
<v-text-field
|
||||
v-model="filterDate"
|
||||
type="date"
|
||||
label="Tanggal"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 150px;" class="mr-4">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<v-btn color="primary" @click="searchData" class="mr-3">
|
||||
SEARCH
|
||||
</v-btn>
|
||||
<v-btn color="success" variant="outlined" @click="exportLaporan" class="mr-3">
|
||||
Laporan Pasien
|
||||
</v-btn>
|
||||
<v-btn color="info" variant="outlined" @click="exportLaporanPerKlinik">
|
||||
Laporan Pasien Per Klinik
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Table Controls -->
|
||||
<v-row class="mb-3">
|
||||
<v-col cols="12" md="6" class="d-flex align-center">
|
||||
<span class="mr-2 pa-4">Show</span>
|
||||
<div style="width: 100px;">
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<span class="ml-2">entries</span>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="d-flex justify-end">
|
||||
<div class="d-flex align-center pa-4">
|
||||
<span class="mr-2">Search:</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="min-width: 200px"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Data Table -->
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="paginatedItems"
|
||||
:search="search"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
>
|
||||
<!-- Custom Status Column -->
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom Keterangan Column -->
|
||||
<template v-slot:item.keterangan="{ item }">
|
||||
<span :class="getKeteranganClass(item.keterangan)">
|
||||
{{ item.keterangan }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- No Data -->
|
||||
<template #no-data>
|
||||
<div class="text-center pa-4">
|
||||
No data available in table
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-space-between align-center pa-4">
|
||||
<div class="text-body-2 text-grey-darken-1">
|
||||
Showing {{ currentPageStart }} to {{ currentPageEnd }} of {{ totalFilteredItems }} entries
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
:disabled="currentPage === 1"
|
||||
@click="previousPage"
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Previous
|
||||
</v-btn>
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<v-btn
|
||||
v-if="page !== '...'"
|
||||
:color="page === currentPage ? 'primary' : ''"
|
||||
:variant="page === currentPage ? 'flat' : 'text'"
|
||||
@click="goToPage(page)"
|
||||
size="small"
|
||||
class="mx-1"
|
||||
min-width="40"
|
||||
>
|
||||
{{ page }}
|
||||
</v-btn>
|
||||
<span v-else class="mx-1">...</span>
|
||||
</template>
|
||||
<v-btn
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="nextPage"
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Next
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'export-laporan', 'export-laporan-per-klinik'])
|
||||
|
||||
// Filter states
|
||||
const filterDate = ref('')
|
||||
const filterStatus = ref('Semua')
|
||||
const search = ref('')
|
||||
|
||||
// Pagination states
|
||||
const itemsPerPage = ref(10)
|
||||
const currentPage = ref(1)
|
||||
|
||||
// Status options
|
||||
const statusOptions = ['Semua', 'Online', 'Offline', 'Tunggu Daftar', 'Barcode']
|
||||
|
||||
// Table headers
|
||||
const headers = [
|
||||
{ title: 'No', key: 'no', sortable: false, width: '60px' },
|
||||
{ title: 'Tgl Periksa', key: 'tglPeriksa', sortable: true },
|
||||
{ title: 'NIK', key: 'nik', sortable: true },
|
||||
{ title: 'RM', key: 'rm', sortable: true },
|
||||
{ title: 'Barcode', key: 'barcode', sortable: true },
|
||||
{ title: 'No Antrian', key: 'noAntrian', sortable: true },
|
||||
{ title: 'Klinik', key: 'klinik', sortable: true },
|
||||
{ title: 'First Name Last Name', key: 'fullName', sortable: true },
|
||||
{ title: 'Shift', key: 'shift', sortable: true },
|
||||
{ title: 'Pembayaran', key: 'pembayaran', sortable: true },
|
||||
{ title: 'Keterangan', key: 'keterangan', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true }
|
||||
]
|
||||
|
||||
// Computed properties
|
||||
const filteredItems = computed(() => {
|
||||
let filtered = props.items
|
||||
|
||||
// Filter by date
|
||||
if (filterDate.value) {
|
||||
filtered = filtered.filter(item =>
|
||||
item.tglPeriksa === filterDate.value
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filterStatus.value && filterStatus.value !== 'Semua') {
|
||||
filtered = filtered.filter(item =>
|
||||
item.status === filterStatus.value
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalFilteredItems = computed(() => filteredItems.value.length)
|
||||
const totalPages = computed(() => Math.ceil(totalFilteredItems.value / itemsPerPage.value))
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
return filteredItems.value.slice(start, end).map((item, index) => ({
|
||||
...item,
|
||||
no: start + index + 1
|
||||
}))
|
||||
})
|
||||
|
||||
const currentPageStart = computed(() =>
|
||||
totalFilteredItems.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1
|
||||
)
|
||||
|
||||
const currentPageEnd = computed(() =>
|
||||
Math.min(currentPage.value * itemsPerPage.value, totalFilteredItems.value)
|
||||
)
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
// Methods
|
||||
const searchData = () => {
|
||||
currentPage.value = 1
|
||||
emit('search', {
|
||||
date: filterDate.value,
|
||||
status: filterStatus.value
|
||||
})
|
||||
}
|
||||
|
||||
const exportLaporan = () => {
|
||||
emit('export-laporan')
|
||||
}
|
||||
|
||||
const exportLaporanPerKlinik = () => {
|
||||
// Navigate to laporan pasien per klinik page
|
||||
navigateTo('/laporan-pasien-per-klinik')
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'Online': 'success',
|
||||
'Offline': 'error',
|
||||
'Tunggu Daftar': 'warning',
|
||||
'Barcode': 'info'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
const getKeteranganClass = (keterangan) => {
|
||||
return keterangan === 'Online' ? 'text-success' : ''
|
||||
}
|
||||
|
||||
const previousPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
}
|
||||
}
|
||||
|
||||
const goToPage = (page) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(itemsPerPage, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch([filterDate, filterStatus], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-success {
|
||||
color: rgb(76, 175, 80) !important;
|
||||
}
|
||||
</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,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
|
||||
@@ -0,0 +1,36 @@
|
||||
# # .env
|
||||
|
||||
# # A random string used to hash tokens, sign cookies, and generate cryptographic keys.
|
||||
# # You can generate one here: https://generate-secret.vercel.app/32
|
||||
# NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
|
||||
|
||||
# # The base URL of your application
|
||||
# AUTH_ORIGIN="http://localhost:3000"
|
||||
|
||||
# # Keycloak Credentials
|
||||
# # Get these from your Keycloak client configuration
|
||||
# KEYCLOAK_CLIENT_ID="nuxt-app"
|
||||
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
|
||||
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
|
||||
|
||||
|
||||
# .env
|
||||
|
||||
# A random string used to hash tokens, sign cookies, and generate cryptographic keys.
|
||||
# You can generate one here: https://generate-secret.vercel.app/32
|
||||
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
|
||||
|
||||
# The base URL of your application
|
||||
# AUTH_ORIGIN="http://localhost:3000"
|
||||
|
||||
# Keycloak Credentials
|
||||
# Get these from your Keycloak client configuration
|
||||
# KEYCLOAK_CLIENT_ID="nuxt-app"
|
||||
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
|
||||
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
|
||||
|
||||
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||
KEYCLOAK_CLIENT_SECRET="FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg"
|
||||
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||
# AUTH_ORIGIN="http://10.10.150.175:3001"
|
||||
AUTH_ORIGIN="http://localhost:3001"
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<v-app id="inspire">
|
||||
<SideBar
|
||||
:items="filteredNavItems"
|
||||
v-model:drawer="drawer"
|
||||
:rail="rail"
|
||||
@toggle-rail="rail = !rail"
|
||||
/>
|
||||
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'permissions']
|
||||
})
|
||||
|
||||
// State for controlling the sidebar
|
||||
const drawer = ref(true);
|
||||
const rail = ref(true);
|
||||
|
||||
const navItemsStore = useNavItemsStore();
|
||||
const { user, checkAuth } = useAuth();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
/* Global styles for layout */
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<!-- layouts/default.vue -->
|
||||
<template>
|
||||
<v-app id="inspire">
|
||||
<AppBar @toggle-rail="rail = !rail" />
|
||||
<SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
|
||||
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from "vue";
|
||||
import AppBar from "../components/AppBar.vue";
|
||||
import SideBar from "../components/SideBar.vue";
|
||||
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const drawer = ref(true);
|
||||
const rail = ref(true);
|
||||
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Your logic to check user access and filter the menu can go here
|
||||
// For example:
|
||||
// const filteredItems = computed(() => {
|
||||
// return navItemsStore.navItems.filter(item => userHasAccess(item.path));
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Global styles for layout */
|
||||
</style>
|
||||
+29
-73
@@ -1,71 +1,25 @@
|
||||
// export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// console.log('🛡️ Auth middleware triggered for:', to.path)
|
||||
|
||||
// // Skip middleware on server-side during build/generation
|
||||
// if (process.server && process.env.NODE_ENV === 'development') {
|
||||
// console.log('⏭️ Skipping auth check on server-side during development')
|
||||
// return
|
||||
// }
|
||||
|
||||
// // Allow the login page to handle its own checks
|
||||
// if (to.path === '/LoginPage') {
|
||||
// console.log('⏭️ Allowing access to LoginPage')
|
||||
// return
|
||||
// }
|
||||
|
||||
// // This is the crucial change: check for the authentication signal
|
||||
// const isAuthRedirect = to.query.authenticated === 'true';
|
||||
|
||||
// // If this is a redirect from a successful login, we need to let the route load
|
||||
// if (isAuthRedirect) {
|
||||
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||
// // We navigate to a clean URL to remove the query parameter
|
||||
// return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
// }
|
||||
|
||||
// try {
|
||||
// console.log('🔍 Checking authentication status...')
|
||||
|
||||
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
|
||||
|
||||
// if (session && session.user) {
|
||||
// console.log('✅ User is authenticated:', session.user.name || session.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')
|
||||
// }
|
||||
// })
|
||||
|
||||
|
||||
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
// Define the shape of the user object returned by your authentication API.
|
||||
// This provides type safety for session.user.
|
||||
interface User {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
// Add other properties from your user object as needed.
|
||||
}
|
||||
|
||||
// Define the shape of the full session object returned by the API.
|
||||
interface Session {
|
||||
user: User;
|
||||
}
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
|
||||
console.log('🛡️ Auth middleware triggered for:', to.path);
|
||||
|
||||
// Skip middleware on server-side during development build/generation
|
||||
if (process.server && process.env.NODE_ENV === 'development') {
|
||||
console.log('⏭️ Skipping auth check on server-side during development');
|
||||
return;
|
||||
// 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
|
||||
@@ -73,26 +27,30 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
|
||||
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 this is a redirect from a successful login, allow the route to load.
|
||||
// We navigate to a clean URL to remove the query parameter.
|
||||
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 {
|
||||
console.log('🔍 Checking authentication status...');
|
||||
const { checkAuth } = useAuth();
|
||||
console.log('🔍 Checking authentication status using useAuth...');
|
||||
|
||||
// Use the defined Session interface to type the fetch response
|
||||
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
|
||||
const user = await checkAuth();
|
||||
|
||||
// Check if a valid session and user exist using optional chaining
|
||||
if (session?.user) {
|
||||
console.log('✅ User is authenticated:', session.user.name || session.user.email);
|
||||
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');
|
||||
@@ -103,6 +61,4 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
|
||||
console.log('🔄 Redirecting to login due to error');
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+96
-32
@@ -1,31 +1,76 @@
|
||||
// nuxt.config.ts
|
||||
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',
|
||||
'@pinia/nuxt',
|
||||
// Remove '@sidebase/nuxt-auth' completely
|
||||
(_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);
|
||||
}
|
||||
});
|
||||
},
|
||||
],
|
||||
|
||||
// Remove the auth configuration completely
|
||||
// auth: { ... } <- Remove this entire block
|
||||
fontawesome: {
|
||||
icons: {
|
||||
solid: ["dna", "user", "home", "gear"],
|
||||
regular: ["heart"],
|
||||
brands: ["github"],
|
||||
},
|
||||
},
|
||||
googleFonts: {
|
||||
families: {
|
||||
Inter: [400, 500, 600, 700],
|
||||
},
|
||||
display: "swap",
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
authSecret: process.env.NUXT_AUTH_SECRET,
|
||||
@@ -33,23 +78,42 @@ export default defineNuxtConfig({
|
||||
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
|
||||
public: {
|
||||
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001' || 'http://localhost:3000'
|
||||
}
|
||||
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']
|
||||
transpile: ["vuetify"],
|
||||
},
|
||||
|
||||
css: [
|
||||
'vuetify/lib/styles/main.sass',
|
||||
'@mdi/font/css/materialdesignicons.min.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: {
|
||||
ssr: {
|
||||
noExternal: ['vuetify']
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
@use "sass:math";
|
||||
@use "sass:map";
|
||||
@use "~/assets/scss/_variables.scss" as *;
|
||||
@use "~/assets/scss/_colors.scss" as *;
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
vuetify({ autoImport: true })
|
||||
]
|
||||
}
|
||||
})
|
||||
ssr: {
|
||||
noExternal: ["vuetify"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+3378
-4520
File diff suppressed because it is too large
Load diff
+17
-1
@@ -4,12 +4,19 @@
|
||||
"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": "^2.7.2",
|
||||
"@nuxt/eslint": "^1.7.1",
|
||||
@@ -19,12 +26,17 @@
|
||||
"@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",
|
||||
@@ -32,6 +44,10 @@
|
||||
"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>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<v-container fluid class="bg-grey-lighten-4 pa-4">
|
||||
<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">Admin Anjungan</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
label="Barcode"
|
||||
placeholder="Masukkan Barcode"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-chip color="#B71C1C" class="text-caption" text-color="white">
|
||||
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
|
||||
J20073010005 "Hiraukan huruf 'J' nya")
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<v-btn block color="#ff9248" style="color: white"
|
||||
>Pendaftaran Online</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider :thickness="5" color="deep-orange-darken-4"></v-divider>
|
||||
|
||||
<TabelData
|
||||
:headers="lateHeaders"
|
||||
:items="lateVisitors"
|
||||
title="DATA PENGUNJUNG TERLAMBAT"
|
||||
/>
|
||||
|
||||
<v-divider :thickness="5" color="deep-orange-darken-4"></v-divider>
|
||||
|
||||
<TabelData
|
||||
:headers="mainHeaders"
|
||||
:items="mainPatients"
|
||||
title="DATA PENGUNJUNG"
|
||||
>
|
||||
<template v-slot:actions="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
small
|
||||
color="#ff9248"
|
||||
class="d-flex flex-row"
|
||||
variant="flat"
|
||||
style="color: white"
|
||||
>PASIEN</v-btn
|
||||
>
|
||||
<v-btn
|
||||
small
|
||||
color="grey-lighten-3"
|
||||
class="d-flex flex-row"
|
||||
variant="flat"
|
||||
>PENGANTAR</v-btn
|
||||
>
|
||||
<v-btn small color="info" class="d-flex flex-row" variant="flat"
|
||||
>ByPass</v-btn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import TabelData from "../components/TabelData.vue"; // Pastikan path-nya benar
|
||||
|
||||
// Ini adalah data yang akan menjadi "single source of truth"
|
||||
// untuk tabel Anda. Data ini dikirim sebagai props ke komponen anak.
|
||||
|
||||
const mainHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Tgl Daftar", value: "tglDaftar", sortable: true },
|
||||
{ title: "RM", value: "rm", sortable: true },
|
||||
{ title: "Barcode", value: "barcode", sortable: true },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true },
|
||||
{ title: "No Klinik", value: "noKlinik", sortable: true },
|
||||
{ title: "Shift", value: "shift", sortable: true },
|
||||
{ title: "Klinik", value: "klinik", sortable: true },
|
||||
{ title: "Pembayaran", value: "pembayaran", sortable: true },
|
||||
{ title: "Masuk", value: "masuk", sortable: true },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const mainPatients = ref([
|
||||
{
|
||||
no: 1,
|
||||
tglDaftar: "12:49",
|
||||
rm: "250811100163",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321232",
|
||||
shift: "Shift 1",
|
||||
klinik: "KANDUNGAN",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
tglDaftar: "18:23",
|
||||
rm: "42081123200199",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321985",
|
||||
shift: "Shift 1",
|
||||
klinik: "DALAM",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
tglDaftar: "02:19",
|
||||
rm: "15092710084",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321777",
|
||||
shift: "Shift 1",
|
||||
klinik: "ANAK",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
tglDaftar: "10:09",
|
||||
rm: "250254310011",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321298",
|
||||
shift: "Shift 1",
|
||||
klinik: "JANTUNG",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
]);
|
||||
|
||||
const lateHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
// Tambahkan headers spesifik untuk tabel ini jika berbeda
|
||||
]);
|
||||
|
||||
const lateVisitors = ref([
|
||||
// Tambahkan data spesifik untuk tabel ini jika ada
|
||||
]);
|
||||
|
||||
// ... Sisa kode lainnya yang tidak terkait dengan tabel ...
|
||||
const items = ref([
|
||||
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
|
||||
{
|
||||
title: "Setting",
|
||||
icon: "mdi-cog",
|
||||
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" },
|
||||
{ title: "Ranap Admin", icon: "mdi-bed" },
|
||||
{ title: "Klinik Admin", icon: "mdi-hospital-box" },
|
||||
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker" },
|
||||
{ title: "Anjungan", icon: "mdi-account-box-multiple", to: "/anjungan" },
|
||||
{ title: "Fast Track", icon: "mdi-clock-fast" },
|
||||
{ title: "Data Pasien", icon: "mdi-account-multiple" },
|
||||
{ title: "Screen", icon: "mdi-monitor" },
|
||||
{ title: "List Pasien", icon: "mdi-format-list-bulleted" },
|
||||
]);
|
||||
</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;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,189 +0,0 @@
|
||||
<template>
|
||||
<v-divider class="my-8"></v-divider>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri -->
|
||||
<div :style="contentStyle">
|
||||
<v-container fluid>
|
||||
<h1 class="text-h4">Admin Anjungan</h1>
|
||||
<v-card class="pa-5 mb-5" color="white" flat>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
label="Barcode"
|
||||
placeholder="Masukkan Barcode"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-chip color="#B71C1C" class="text-caption">
|
||||
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
|
||||
J20073010005 "Hiraukan huruf 'J' nya")
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<v-btn block color="info">Pendaftaran Online</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="my-5"></v-divider>
|
||||
|
||||
<v-card class="mb-5">
|
||||
<v-toolbar flat color="transparent" dense>
|
||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
|
||||
DATA PENGUNJUNG TERLAMBAT
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="searchLate"
|
||||
append-icon="mdi-magnify"
|
||||
label="Search"
|
||||
single-line
|
||||
hide-details
|
||||
dense
|
||||
class="mr-2"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-select
|
||||
:items="[10, 25, 50, 100]"
|
||||
label="Show"
|
||||
dense
|
||||
single-line
|
||||
hide-details
|
||||
class="shrink"
|
||||
variant="outlined"
|
||||
></v-select>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="lateHeaders"
|
||||
:items="lateVisitors"
|
||||
:search="searchLate"
|
||||
no-data-text="No data available in table"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
></v-data-table>
|
||||
<div class="d-flex justify-end pt-2">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="10"
|
||||
:total-visible="5"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="my-5"></v-divider>
|
||||
|
||||
<v-card>
|
||||
<v-toolbar flat color="transparent" dense>
|
||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
|
||||
DATA PENGUNJUNG
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
append-icon="mdi-magnify"
|
||||
label="Search"
|
||||
single-line
|
||||
hide-details
|
||||
dense
|
||||
class="mr-2"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-select
|
||||
:items="[10, 25, 50, 100]"
|
||||
label="Show"
|
||||
dense
|
||||
single-line
|
||||
hide-details
|
||||
class="shrink"
|
||||
variant="outlined"
|
||||
></v-select>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="visitors"
|
||||
:search="search"
|
||||
no-data-text="No data available in table"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<div class="d-flex flex-column">
|
||||
<v-btn small color="success" class="my-1">Tiket</v-btn>
|
||||
<v-btn small color="primary" class="my-1">Tiket Pengantar</v-btn>
|
||||
<v-btn small color="warning" class="my-1">ByPass</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// Definisikan props untuk menerima status 'rail' dari layout induk
|
||||
const props = defineProps({
|
||||
rail: Boolean,
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const search = ref("");
|
||||
const searchLate = ref("");
|
||||
const page = ref(1);
|
||||
|
||||
// Gaya komputasi untuk menyesuaikan padding
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
paddingLeft: props.rail ? '56px' : '64px',
|
||||
transition: 'padding-left 0.3s ease-in-out',
|
||||
};
|
||||
});
|
||||
|
||||
// Table headers for late visitors
|
||||
const lateHeaders = [
|
||||
{ text: 'No', value: 'no' },
|
||||
{ text: 'Barcode', value: 'barcode' },
|
||||
{ text: 'No Rekamedik', value: 'noRekamedik' },
|
||||
{ text: 'No Antrian', value: 'noAntrian' },
|
||||
{ text: 'No Antrian Klinik', value: 'noAntrianKlinik' },
|
||||
{ text: 'Shift', value: 'shift' },
|
||||
{ text: 'Pembayaran', value: 'pembayaran' },
|
||||
{ text: 'Status', value: 'status' },
|
||||
];
|
||||
|
||||
// Table headers for all visitors
|
||||
const headers = [
|
||||
{ text: 'No', value: 'no' },
|
||||
{ text: 'Barcode', value: 'barcode' },
|
||||
{ text: 'No Rekamedik', value: 'noRekamedik' },
|
||||
{ text: 'No Antrian', value: 'noAntrian' },
|
||||
{ text: 'Shift', value: 'shift' },
|
||||
{ text: 'Ket', value: 'ket' },
|
||||
{ text: 'Fast Track', value: 'fastTrack' },
|
||||
{ text: 'Pembayaran', value: 'pembayaran' },
|
||||
{ text: 'Panggil', value: 'panggil' },
|
||||
{ text: 'Aksi', value: 'aksi' },
|
||||
];
|
||||
|
||||
// Mock data for late visitors
|
||||
const lateVisitors = ref([
|
||||
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', noAntrianKlinik: 'K1', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
||||
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', noAntrianKlinik: 'K2', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
||||
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', noAntrianKlinik: 'K3', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
||||
]);
|
||||
|
||||
// Mock data for all visitors
|
||||
const visitors = ref([
|
||||
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', shift: 'Shift 1', ket: '', fastTrack: 'Ya', pembayaran: 'JKN', panggil: 'Ya' },
|
||||
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
|
||||
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
|
||||
]);
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load diff
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>
|
||||
|
||||
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>
|
||||
+482
-214
@@ -1,114 +1,153 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-6 bg-grey-lighten-4">
|
||||
<div class="d-flex justify-space-between align-center mb-6">
|
||||
<v-container fluid class="pa-8 dashboard-bg">
|
||||
|
||||
<div class="d-flex justify-space-between align-center mb-10">
|
||||
<div>
|
||||
<h1 class="text-h4 font-weight-bold">Dashboard</h1>
|
||||
<p v-if="user" class="text-subtitle-1 text-grey-darken-1 mt-1">
|
||||
Selamat Datang, {{ user.name || user.preferred_username }}!
|
||||
<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-chip color="green-lighten-1" class="mr-2 pa-3 font-weight-bold">
|
||||
<v-icon start icon="mdi-calendar"></v-icon>
|
||||
|
||||
<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-6">
|
||||
<v-row class="mb-10">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card class="pa-4 rounded-xl elevation-6" color="blue-lighten-1" theme="dark">
|
||||
<v-card class="stat-card stat-card-primary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="64" class="mr-4">mdi-account-group</v-icon>
|
||||
<v-icon size="48" color="primary-600" class="mr-4">mdi-tablet-dashboard</v-icon>
|
||||
<div>
|
||||
<div class="text-h4 font-weight-black">2635</div>
|
||||
<div class="text-subtitle-1">Total Visitors</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="pa-4 rounded-xl elevation-6" color="cyan-lighten-1" theme="dark">
|
||||
<v-card class="stat-card stat-card-secondary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="64" class="mr-4">mdi-account-multiple-plus</v-icon>
|
||||
<v-icon size="48" color="secondary-600" class="mr-4">mdi-account-hard-hat</v-icon>
|
||||
<div>
|
||||
<div class="text-h4 font-weight-black">759</div>
|
||||
<div class="text-subtitle-1">Offline Registrants</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="pa-4 rounded-xl elevation-6" color="green-lighten-1" theme="dark">
|
||||
<v-card class="stat-card stat-card-primary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="64" class="mr-4">mdi-account-multiple-plus-outline</v-icon>
|
||||
<v-icon size="48" color="primary-600" class="mr-4">mdi-account-group</v-icon>
|
||||
<div>
|
||||
<div class="text-h4 font-weight-black">1876</div>
|
||||
<div class="text-subtitle-1">Online Registrants</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="pa-4 rounded-xl elevation-6" color="orange-lighten-1" theme="dark">
|
||||
<v-card class="stat-card stat-card-secondary">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="64" class="mr-4">mdi-ticket</v-icon>
|
||||
<v-icon size="48" color="secondary-600" class="mr-4">mdi-timer-sand</v-icon>
|
||||
<div>
|
||||
<div class="text-h4 font-weight-black">248</div>
|
||||
<div class="text-subtitle-1">Total Tickets Printed</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>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="pa-4 rounded-xl elevation-6">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h6 font-weight-bold">Grafik Jumlah Antrean</span>
|
||||
<v-btn-toggle v-model="queueTimePeriod" mandatory divided variant="outlined" color="primary" class="text-caption">
|
||||
<v-btn size="small" value="day">Hari</v-btn>
|
||||
<v-btn size="small" value="week">Minggu</v-btn>
|
||||
<v-btn size="small" value="month">Bulan</v-btn>
|
||||
<v-btn size="small" value="year">Tahun</v-btn>
|
||||
<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>
|
||||
<v-card-text class="pa-0">
|
||||
<Bar
|
||||
:data="queueChartData"
|
||||
:options="queueChartOptions"
|
||||
style="height: 300px"
|
||||
style="height: 350px"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="pa-4 rounded-xl elevation-6">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h6 font-weight-bold">Monthly Visitor Data</span>
|
||||
<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' ? 'green-lighten-1' : 'grey-lighten-2'"
|
||||
class="mr-2 text-caption font-weight-bold cursor-pointer"
|
||||
: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' ? 'green-lighten-1' : 'grey-lighten-2'"
|
||||
class="text-caption font-weight-bold cursor-pointer"
|
||||
: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>
|
||||
<v-card-text class="pa-0">
|
||||
<Bar
|
||||
:data="barData"
|
||||
:options="barOptions"
|
||||
style="height: 300px"
|
||||
style="height: 350px"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -117,26 +156,33 @@
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="pa-4 rounded-xl elevation-6">
|
||||
<v-card-title class="text-h6 font-weight-bold">Realtime Ticket Queue</v-card-title>
|
||||
<v-card-text>
|
||||
<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
|
||||
ref="realtimeChart"
|
||||
:data="initialLineData"
|
||||
:data="realtimeLineData"
|
||||
:options="lineOptions"
|
||||
style="height: 300px"
|
||||
style="height: 350px"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="pa-4 rounded-xl elevation-6">
|
||||
<v-card-title class="text-h6 font-weight-bold">Registrant Breakdown</v-card-title>
|
||||
<v-card-text>
|
||||
<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="height: 300px"
|
||||
style="max-height: 100%; max-width: 100%;"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -144,8 +190,8 @@
|
||||
</v-row>
|
||||
|
||||
<v-overlay v-model="isLoading" contained class="align-center justify-center">
|
||||
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
|
||||
<p class="mt-4">Loading dashboard...</p>
|
||||
<v-progress-circular indeterminate size="64" color="primary-600"></v-progress-circular>
|
||||
<p class="loading-text">Loading dashboard...</p>
|
||||
</v-overlay>
|
||||
</v-container>
|
||||
</template>
|
||||
@@ -153,13 +199,15 @@
|
||||
<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';
|
||||
|
||||
// Tambahkan plugin 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,
|
||||
@@ -178,165 +226,168 @@ definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Register necessary Chart.js elements
|
||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
|
||||
|
||||
// Use the auth composable
|
||||
const { user, isLoading, checkAuth, logout } = useAuth()
|
||||
const isLoggingOut = ref(false)
|
||||
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' },
|
||||
]);
|
||||
|
||||
// Dashboard data
|
||||
const currentDate = ref('');
|
||||
const activeYear = ref('2025');
|
||||
const queueTimePeriod = ref('month');
|
||||
const queueTimePeriod = ref('day');
|
||||
|
||||
// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
|
||||
const mockQueueData = ref([
|
||||
// ... (data tetap sama)
|
||||
{ date: '2025-09-22', count: 120 },
|
||||
{ date: '2025-09-23', count: 150 },
|
||||
{ date: '2025-09-24', count: 135 },
|
||||
{ date: '2025-09-25', count: 160 },
|
||||
{ date: '2025-09-26', count: 145 },
|
||||
{ date: '2025-09-27', count: 170 },
|
||||
{ date: '2025-09-28', count: 180 },
|
||||
{ date: '2025-08-10', count: 300 },
|
||||
{ date: '2025-08-20', count: 350 },
|
||||
{ date: '2025-09-01', count: 400 },
|
||||
{ date: '2025-09-15', count: 450 },
|
||||
{ date: '2024-01-01', count: 1200 },
|
||||
{ date: '2024-06-01', count: 1800 },
|
||||
{ date: '2025-01-01', count: 2000 },
|
||||
{ date: '2025-06-01', count: 2500 },
|
||||
{ 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 },
|
||||
]);
|
||||
// --- AKHIR MOCK DATA ANTREEAN ---
|
||||
|
||||
// --- REFACTORED REALTIME LOGIC ---
|
||||
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
|
||||
// --- SIMPLIFIED REALTIME CHART (NO AUTO-UPDATE) ---
|
||||
const maxDataPoints = 10;
|
||||
let realtimeInterval = null;
|
||||
|
||||
// Initial data structure (non-reactive for update function)
|
||||
const initialLineData = {
|
||||
labels: Array.from({ length: maxDataPoints }, (_, i) =>
|
||||
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tickets Processed',
|
||||
backgroundColor: '#FF5722',
|
||||
borderColor: '#FF5722',
|
||||
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
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 // Crucial: Disable animation for smooth realtime scrolling
|
||||
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)' }
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
title: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: 200,
|
||||
title: { display: true, text: 'Count' },
|
||||
grid: { display: true }
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Time (HH:MM:SS)' },
|
||||
grid: { display: false }
|
||||
}
|
||||
x: {
|
||||
title: { display: true, text: 'Time (HH:MM:SS)' },
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
// --- END SIMPLIFIED REALTIME CHART ---
|
||||
|
||||
// Function to simulate realtime data update using chart.update()
|
||||
const updateRealtimeData = () => {
|
||||
const chart = realtimeChart.value?.chart;
|
||||
if (!chart) return;
|
||||
|
||||
// 1. Get the current data arrays
|
||||
const dataArray = chart.data.datasets[0].data;
|
||||
const labelArray = chart.data.labels;
|
||||
|
||||
// 2. Shift (remove) the oldest data point and label
|
||||
dataArray.shift();
|
||||
labelArray.shift();
|
||||
|
||||
// 3. Generate new data point and time label
|
||||
const newDataPoint = Math.floor(Math.random() * 50) + 100;
|
||||
const newTimeLabel = dayjs().format('HH:mm:ss');
|
||||
|
||||
// 4. Push the new data and label
|
||||
dataArray.push(newDataPoint);
|
||||
labelArray.push(newTimeLabel);
|
||||
|
||||
// 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
|
||||
chart.update();
|
||||
};
|
||||
// --- END REFACTORED REALTIME LOGIC ---
|
||||
|
||||
|
||||
// Example data for both years (Tetap)
|
||||
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];
|
||||
|
||||
// Check authentication and setup on page load
|
||||
// Single onMounted
|
||||
onMounted(async () => {
|
||||
console.log('📊 Dashboard mounted');
|
||||
try {
|
||||
const sessionUser = await checkAuth()
|
||||
const sessionUser = await checkAuth();
|
||||
if (sessionUser) {
|
||||
// Set current date
|
||||
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
currentDate.value = new Date().toLocaleDateString('id-ID', options);
|
||||
|
||||
// Start realtime data update interval
|
||||
// Pastikan chart instance sudah siap sebelum memulai interval
|
||||
realtimeInterval = setInterval(updateRealtimeData, 5000);
|
||||
} else {
|
||||
await navigateTo('/LoginPage');
|
||||
user.value = sessionUser;
|
||||
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
|
||||
console.log('✅ Dashboard loaded successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
await navigateTo('/LoginPage');
|
||||
console.error('❌ Auth check error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the interval when the component is unmounted
|
||||
// Clean onUnmounted
|
||||
onUnmounted(() => {
|
||||
if (realtimeInterval) {
|
||||
clearInterval(realtimeInterval);
|
||||
}
|
||||
console.log('🧹 Dashboard unmounting');
|
||||
});
|
||||
|
||||
// Updated logout handler (Tetap)
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut.value) return
|
||||
|
||||
try {
|
||||
isLoggingOut.value = true
|
||||
console.log('🚪 Dashboard logout initiated...')
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error('❌ Dashboard logout error:', error)
|
||||
} finally {
|
||||
isLoggingOut.value = false
|
||||
}
|
||||
};
|
||||
|
||||
// Function to change the active year (Tetap)
|
||||
const changeYear = (year) => {
|
||||
activeYear.value = year;
|
||||
};
|
||||
|
||||
// --- LOGIKA UTAMA UNTUK GRAFIK ANTREEAN (Tetap) ---
|
||||
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 => ({
|
||||
@@ -347,37 +398,49 @@ const processQueueData = (data, period) => {
|
||||
sortedDates.forEach(item => {
|
||||
let key;
|
||||
let label;
|
||||
|
||||
if (period === 'day') {
|
||||
key = item.date.format('YYYY-MM-DD');
|
||||
label = item.date.format('DD/MM');
|
||||
label = item.date.format('ddd, DD/MM');
|
||||
} else if (period === 'week') {
|
||||
key = item.date.format('YYYY-WW');
|
||||
label = `Wk ${item.date.week()} ${item.date.year()}`;
|
||||
label = `Wk ${item.date.week()}`;
|
||||
} else if (period === 'month') {
|
||||
key = item.date.format('YYYY-MM');
|
||||
label = item.date.format('MMM YYYY');
|
||||
} else if (period === 'year') {
|
||||
key = item.date.format('YYYY');
|
||||
label = item.date.format('YYYY');
|
||||
}
|
||||
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = { label: label, count: 0 };
|
||||
grouped[key] = { label: label, registrants: 0, attendees: 0 };
|
||||
}
|
||||
grouped[key].count += item.count;
|
||||
grouped[key].registrants += item.registrants;
|
||||
grouped[key].attendees += item.attendees;
|
||||
});
|
||||
|
||||
const finalLabels = Object.values(grouped).map(g => g.label);
|
||||
const finalCounts = Object.values(grouped).map(g => g.count);
|
||||
const finalRegistrants = Object.values(grouped).map(g => g.registrants);
|
||||
const finalAttendees = Object.values(grouped).map(g => g.attendees);
|
||||
|
||||
return {
|
||||
labels: finalLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: `Total Antrean per ${period}`,
|
||||
backgroundColor: '#FFB300',
|
||||
data: finalCounts,
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -392,66 +455,94 @@ const queueChartOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
legend: { display: true, position: 'top' },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
y1: {
|
||||
beginAtZero: true,
|
||||
grid: { display: true }
|
||||
title: { display: true, text: 'Count' },
|
||||
grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
|
||||
|
||||
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];
|
||||
|
||||
// Computed property Monthly Visitor (Tetap)
|
||||
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 Visitors ${activeYear.value}`,
|
||||
backgroundColor: '#2196F3',
|
||||
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,
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Bar chart options (Tetap)
|
||||
const barOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
legend: { display: true, position: 'top' },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pie chart data (Tetap)
|
||||
const pieData = ref({
|
||||
labels: ['Offline Registrants', 'Online Registrants'],
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: ['#2196F3', '#4CAF50'],
|
||||
backgroundColor: ['#0671E0', '#FFB95F'],
|
||||
hoverBackgroundColor: ['#0053AD', '#FF9B1B'],
|
||||
data: [759, 1876],
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pie chart options (Tetap)
|
||||
const pieOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -461,7 +552,9 @@ const pieOptions = ref({
|
||||
label: function(context) {
|
||||
const label = context.label || '';
|
||||
const value = context.parsed || 0;
|
||||
return `${label}: ${value}`;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,8 +562,183 @@ const pieOptions = ref({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
<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>
|
||||
@@ -1,372 +0,0 @@
|
||||
<template>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6">
|
||||
|
||||
<!-- Colored Header with Quota Chip -->
|
||||
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center justify-space-between pa-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="48" color="white" class="mr-4">mdi-account-group-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Klinik Admin</h1>
|
||||
</div>
|
||||
<v-tooltip text="Jumlah Maksimal Bangku Tersedia" location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
class="text-white px-4 py-2"
|
||||
color="green-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-icon start>mdi-chair-rolling</v-icon>
|
||||
Max Quota Bangku 0
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-card>
|
||||
|
||||
<!-- Loket Admin Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Loket Admin
|
||||
<div>
|
||||
<v-btn
|
||||
color="green-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(1)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-1-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 1 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="blue-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(5)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-5-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 5 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(10)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-10-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 10 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 btn-call-group"
|
||||
@click="handleCallClick(20)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-20-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 20 Antrian</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<!-- Pilihan Show Entries untuk Loket Admin -->
|
||||
<div class="d-flex justify-end mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show Entries:</span>
|
||||
<v-select
|
||||
:items="[10, 25, 50]"
|
||||
v-model="itemsPerPageLoket"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="show-entries-select"
|
||||
></v-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-table class="mt-3 custom-table rounded-lg elevation-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in loketHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paginatedLoketData" :key="index">
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.ket }}</td>
|
||||
<td>{{ item.fastTrack }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-btn size="small" color="primary" class="text-white rounded-lg" @click="handlePanggil(item)">
|
||||
<v-icon start>mdi-phone-incoming</v-icon>
|
||||
Panggil
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn size="small" color="red-darken-1" class="text-white rounded-lg" @click="handleBatalkan(item)">
|
||||
<v-icon start>mdi-close</v-icon>
|
||||
Batalkan
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>
|
||||
Menampilkan {{ (currentPageLoket - 1) * itemsPerPageLoket + 1 }} hingga
|
||||
{{ Math.min(currentPageLoket * itemsPerPageLoket, loketData.length) }} dari
|
||||
{{ loketData.length }} entri
|
||||
</span>
|
||||
<div>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPageLoket === 1" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket - 1)">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPageLoket >= totalPagesLoket" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket + 1)">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Data Pengunjung Table -->
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Data Pengunjung: Loket
|
||||
</v-card-title>
|
||||
|
||||
<!-- Pilihan Show Entries untuk Data Pengunjung -->
|
||||
<div class="d-flex justify-end mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show Entries:</span>
|
||||
<v-select
|
||||
:items="[10, 25, 50]"
|
||||
v-model="itemsPerPagePengunjung"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="show-entries-select"
|
||||
></v-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-table class="mt-3 custom-table rounded-lg elevation-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in pengunjungHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paginatedPengunjungData" :key="index">
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.noAntrianKlinik }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-chip :color="item.status === 'Selesai' ? 'green' : 'orange'" size="small">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>
|
||||
Menampilkan {{ (currentPagePengunjung - 1) * itemsPerPagePengunjung + 1 }} hingga
|
||||
{{ Math.min(currentPagePengunjung * itemsPerPagePengunjung, pengunjungData.length) }} dari
|
||||
{{ pengunjungData.length }} entri
|
||||
</span>
|
||||
<div>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung === 1" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung - 1)">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung >= totalPagesPengunjung" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung + 1)">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Generate dummy data
|
||||
const generateDummyData = (count) => {
|
||||
const data = [];
|
||||
const shiftOptions = ['Pagi', 'Siang', 'Sore'];
|
||||
const pembayaranOptions = ['BPJS', 'Umum', 'Asuransi'];
|
||||
const statusOptions = ['Menunggu', 'Selesai', 'Di-cancel'];
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
data.push({
|
||||
no: i,
|
||||
barcode: `B${1000 + i}`,
|
||||
noRekamedik: `RM${100 + i}`,
|
||||
noAntrian: `A${100 + i}`,
|
||||
noAntrianKlinik: `K${100 + i}`,
|
||||
shift: shiftOptions[Math.floor(Math.random() * shiftOptions.length)],
|
||||
ket: "Dummy Data",
|
||||
fastTrack: Math.random() > 0.5 ? "Ya" : "Tidak",
|
||||
pembayaran: pembayaranOptions[Math.floor(Math.random() * pembayaranOptions.length)],
|
||||
status: statusOptions[Math.floor(Math.random() * statusOptions.length)]
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// State for Loket Admin table
|
||||
const loketData = ref(generateDummyData(50));
|
||||
const currentPageLoket = ref(1);
|
||||
const itemsPerPageLoket = ref(10);
|
||||
|
||||
// State for Data Pengunjung table
|
||||
const pengunjungData = ref(generateDummyData(50));
|
||||
const currentPagePengunjung = ref(1);
|
||||
const itemsPerPagePengunjung = ref(10);
|
||||
|
||||
// Computed properties for Loket Admin table
|
||||
const paginatedLoketData = computed(() => {
|
||||
const start = (currentPageLoket.value - 1) * itemsPerPageLoket.value;
|
||||
const end = start + itemsPerPageLoket.value;
|
||||
return loketData.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPagesLoket = computed(() => {
|
||||
return Math.ceil(loketData.value.length / itemsPerPageLoket.value);
|
||||
});
|
||||
|
||||
// Computed properties for Data Pengunjung table
|
||||
const paginatedPengunjungData = computed(() => {
|
||||
const start = (currentPagePengunjung.value - 1) * itemsPerPagePengunjung.value;
|
||||
const end = start + itemsPerPagePengunjung.value;
|
||||
return pengunjungData.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPagesPengunjung = computed(() => {
|
||||
return Math.ceil(pengunjungData.value.length / itemsPerPagePengunjung.value);
|
||||
});
|
||||
|
||||
// Method to handle page change for Loket Admin table
|
||||
const handlePageChangeLoket = (page) => {
|
||||
if (page >= 1 && page <= totalPagesLoket.value) {
|
||||
currentPageLoket.value = page;
|
||||
}
|
||||
};
|
||||
|
||||
// Method to handle page change for Data Pengunjung table
|
||||
const handlePageChangePengunjung = (page) => {
|
||||
if (page >= 1 && page <= totalPagesPengunjung.value) {
|
||||
currentPagePengunjung.value = page;
|
||||
}
|
||||
};
|
||||
|
||||
// Methods to handle button clicks (unchanged)
|
||||
const loketHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Ket' },
|
||||
{ text: 'Fast Track' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Panggil' },
|
||||
{ text: 'Aksi' },
|
||||
];
|
||||
|
||||
const pengunjungHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'No Antrian Klinik' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Status' },
|
||||
];
|
||||
|
||||
const handleCallClick = (value) => {
|
||||
console.log(`Panggil ${value} antrian diklik!`);
|
||||
};
|
||||
|
||||
const handlePanggil = (item) => {
|
||||
console.log('Panggil pasien:', item);
|
||||
};
|
||||
|
||||
const handleBatalkan = (item) => {
|
||||
console.log('Batalkan pasien:', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Main container padding */
|
||||
.v-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.header-banner {
|
||||
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* General card styling */
|
||||
.v-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Call buttons group */
|
||||
.btn-call-group {
|
||||
min-width: 50px;
|
||||
height: 40px !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.custom-table {
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.custom-table :deep(th) {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.custom-table :deep(tr:hover) {
|
||||
background-color: #e8eaf6 !important; /* Light blue on hover */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
margin: 0 4px;
|
||||
background-color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
/* Custom styling for status chip */
|
||||
.v-chip.v-chip--size-small {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.show-entries-select {
|
||||
max-width: 100px;
|
||||
}
|
||||
</style>
|
||||
+142
-632
@@ -1,78 +1,6 @@
|
||||
<!-- // Pages/LoginPage.vue -->
|
||||
|
||||
<template>
|
||||
<v-container fluid fill-height class="login-background">
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<v-app-bar class="navbar" flat>
|
||||
<v-toolbar-title class="brand-logo">
|
||||
<v-icon class="mr-2" color="white">mdi-hospital-building</v-icon>
|
||||
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold" >RSSA</span>
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- Navigation with Dropdown Menus -->
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="white"
|
||||
class="nav-link"
|
||||
v-bind="props"
|
||||
>
|
||||
Tentang
|
||||
<v-icon right small>mdi-chevron-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list class="nav-dropdown">
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-hospital-building</v-icon>
|
||||
<v-list-item-title>Profil Rumah Sakit</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="white"
|
||||
class="nav-link"
|
||||
v-bind="props"
|
||||
>
|
||||
Kontak
|
||||
<v-icon right small>mdi-chevron-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list class="nav-dropdown">
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-phone</v-icon>
|
||||
<v-list-item-title>Hubungi Kami</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-map-marker</v-icon>
|
||||
<v-list-item-title>Alamat & Lokasi</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-email</v-icon>
|
||||
<v-list-item-title>Email</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-help-circle</v-icon>
|
||||
<v-list-item-title>Bantuan</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-btn icon color="white" class="ml-4">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- Floating Medical Icons Background -->
|
||||
<div class="floating-medical-icon icon-1">
|
||||
<v-icon size="144">mdi-heart-pulse</v-icon>
|
||||
</div>
|
||||
@@ -93,168 +21,139 @@
|
||||
</div>
|
||||
|
||||
<v-row class="fill-height align-center justify-center">
|
||||
<!-- Left Content -->
|
||||
<v-col cols="12" md="6" class="text-section">
|
||||
<div class="hero-content">
|
||||
<div class="logo-section mb-6">
|
||||
<img
|
||||
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
|
||||
alt="Logo Rumah Sakit"
|
||||
class="mt-3 hospital-logo"
|
||||
/>
|
||||
<h1 class="hero-title">Sistem Terbaik</h1>
|
||||
<h1 class="hero-title">Untuk Pelayanan</h1>
|
||||
<h1 class="hero-title">Kesehatan</h1>
|
||||
</div>
|
||||
|
||||
<p class="hero-description">
|
||||
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
|
||||
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
|
||||
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
|
||||
</p>
|
||||
</div>
|
||||
</v-col>
|
||||
<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>
|
||||
|
||||
<!-- Right Login Card -->
|
||||
<v-col cols="12" md="6" class="d-flex justify-center">
|
||||
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
|
||||
|
||||
<!-- Header -->
|
||||
<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>
|
||||
|
||||
<!-- Logo Section -->
|
||||
<div class="d-flex flex-column align-center text-center mb-6">
|
||||
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
|
||||
<img
|
||||
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
|
||||
alt="Logo Rumah Sakit"
|
||||
class="mt-3 hospital-logo"
|
||||
/>
|
||||
</div>
|
||||
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<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-alert v-if="successMessage" type="success" class="mb-4">
|
||||
{{ successMessage }}
|
||||
</v-alert>
|
||||
|
||||
|
||||
<!-- SSO Info -->
|
||||
<div class="text-center mb-6">
|
||||
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Keycloak Login Button -->
|
||||
<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>
|
||||
|
||||
<!-- Registration Section -->
|
||||
<v-divider class="my-6 custom-divider-dark"></v-divider>
|
||||
|
||||
<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 mb-6">
|
||||
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span class="help-text-dark">
|
||||
Belum memiliki akun?
|
||||
</span>
|
||||
<br>
|
||||
<v-btn
|
||||
@click="showAdminContact = true"
|
||||
variant="text"
|
||||
color="#0053AD"
|
||||
size="small"
|
||||
class="contact-link-dark mt-1"
|
||||
>
|
||||
Hubungi Administrator
|
||||
</v-btn>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Password Help -->
|
||||
<div class="text-center mt-4">
|
||||
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Registration Information Dialog -->
|
||||
<v-dialog v-model="showRegistrationDialog" max-width="500">
|
||||
<!-- <v-dialog v-model="showRegistrationDialog" max-width="500">
|
||||
<v-card class="white-dialog rounded-xl">
|
||||
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
|
||||
<v-icon left color="#0053AD">mdi-account-plus</v-icon>
|
||||
Pendaftaran Akun Baru
|
||||
<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="#0053AD" class="mb-4">mdi-information</v-icon>
|
||||
</div>
|
||||
|
||||
<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="#0053AD">mdi-numeric-1-circle</v-icon>
|
||||
</template>
|
||||
<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="#0053AD">mdi-numeric-2-circle</v-icon>
|
||||
</template>
|
||||
<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="#0053AD">mdi-numeric-3-circle</v-icon>
|
||||
</template>
|
||||
<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
|
||||
@@ -267,34 +166,29 @@
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = false; showAdminContact = true"
|
||||
color="#0053AD"
|
||||
rounded
|
||||
color="#FF9B1B" rounded
|
||||
>
|
||||
<v-icon left>mdi-phone</v-icon>
|
||||
Hubungi Admin
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-dialog> -->
|
||||
|
||||
<!-- Admin Contact Dialog -->
|
||||
<v-dialog v-model="showAdminContact" max-width="500">
|
||||
<!-- <v-dialog v-model="showAdminContact" max-width="500">
|
||||
<v-card class="white-dialog rounded-xl">
|
||||
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
|
||||
<v-icon left color="#0053AD">mdi-account-tie</v-icon>
|
||||
Kontak Administrator
|
||||
<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="#0053AD" class="mb-4">mdi-phone-settings</v-icon>
|
||||
</div>
|
||||
|
||||
<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="#0053AD">mdi-office-building</v-icon>
|
||||
</template>
|
||||
<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
|
||||
@@ -304,11 +198,10 @@
|
||||
</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="#0053AD">mdi-phone</v-icon>
|
||||
</template>
|
||||
<v-icon color="#FF9B1B">mdi-phone</v-icon> </template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
(0341) 343343 ext. 1234
|
||||
@@ -318,11 +211,10 @@
|
||||
</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="#0053AD">mdi-email</v-icon>
|
||||
</template>
|
||||
<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
|
||||
@@ -332,11 +224,10 @@
|
||||
</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="#0053AD">mdi-clock</v-icon>
|
||||
</template>
|
||||
<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
|
||||
@@ -347,19 +238,18 @@
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
color="blue"
|
||||
>
|
||||
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
|
||||
@@ -372,15 +262,14 @@
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="copyContactInfo"
|
||||
color="#0053AD"
|
||||
rounded
|
||||
color="#FF9B1B" rounded
|
||||
>
|
||||
<v-icon left>mdi-content-copy</v-icon>
|
||||
Salin Info
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-dialog> -->
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
@@ -401,6 +290,14 @@ 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(() => {
|
||||
@@ -409,7 +306,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Custom login handler
|
||||
// Custom login handler (pointing to Keycloak SSO logic)
|
||||
const handleLogin = async (): Promise<void> => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -417,7 +314,8 @@ const handleLogin = async (): Promise<void> => {
|
||||
|
||||
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'
|
||||
})
|
||||
@@ -425,7 +323,8 @@ const handleLogin = async (): Promise<void> => {
|
||||
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)
|
||||
@@ -434,435 +333,46 @@ const handleLogin = async (): Promise<void> => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error)
|
||||
// Display the error message
|
||||
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
// 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, 08:00 - 16:00
|
||||
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>
|
||||
/* Main Background */
|
||||
.login-background {
|
||||
background: linear-gradient(135deg, #f1b464 0%, #faa22e 25%, #e49458 50%, #e46f30 75%, #e26450 100%);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Navigation Bar */
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: white !important;
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Floating Medical Icons */
|
||||
.floating-medical-icon {
|
||||
position: absolute;
|
||||
animation: dvdBounce 20s linear infinite;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.floating-medical-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floating-medical-icon .v-icon {
|
||||
color: rgba(255, 255, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.icon-1 {
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 25s;
|
||||
animation-name: dvdBounce1;
|
||||
}
|
||||
|
||||
.icon-1 .v-icon {
|
||||
color: rgba(6, 37, 83, 0.15) !important;
|
||||
}
|
||||
|
||||
.icon-2 {
|
||||
top: 0%;
|
||||
right: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 30s;
|
||||
animation-name: dvdBounce2;
|
||||
}
|
||||
|
||||
.icon-2 .v-icon {
|
||||
color: rgba(15, 180, 70, 0.12) !important;
|
||||
}
|
||||
|
||||
.icon-3 {
|
||||
bottom: 0%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 22s;
|
||||
animation-name: dvdBounce3;
|
||||
}
|
||||
|
||||
.icon-3 .v-icon {
|
||||
color: rgba(23, 178, 206, 0.18) !important;
|
||||
}
|
||||
|
||||
.icon-4 {
|
||||
top: 50%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 28s;
|
||||
animation-name: dvdBounce4;
|
||||
}
|
||||
|
||||
.icon-4 .v-icon {
|
||||
color: rgba(223, 8, 8, 0.14) !important;
|
||||
}
|
||||
|
||||
.icon-5 {
|
||||
bottom: 0%;
|
||||
right: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 26s;
|
||||
animation-name: dvdBounce5;
|
||||
}
|
||||
|
||||
.icon-5 .v-icon {
|
||||
color: rgba(148, 15, 236, 0.16) !important;
|
||||
}
|
||||
|
||||
.icon-6 {
|
||||
top: 25%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 24s;
|
||||
animation-name: dvdBounce6;
|
||||
}
|
||||
|
||||
.icon-6 .v-icon {
|
||||
color: rgba(87, 48, 5, 0.13) !important;
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
|
||||
@keyframes dvdBounce1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(80vw, 70vh); }
|
||||
50% { transform: translate(20vw, 10vh); }
|
||||
75% { transform: translate(70vw, 80vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
|
||||
@keyframes dvdBounce2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-75vw, 60vh); }
|
||||
50% { transform: translate(-30vw, 20vh); }
|
||||
75% { transform: translate(-85vw, 75vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
|
||||
@keyframes dvdBounce3 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(70vw, -60vh); }
|
||||
50% { transform: translate(40vw, -80vh); }
|
||||
75% { transform: translate(90vw, -30vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 4 - Middle Left across screen */
|
||||
@keyframes dvdBounce4 {
|
||||
0% { transform: translate(0, 0); }
|
||||
16.6% { transform: translate(60vw, -30vh); }
|
||||
33.3% { transform: translate(90vw, 20vh); }
|
||||
50% { transform: translate(50vw, 40vh); }
|
||||
66.6% { transform: translate(10vw, -20vh); }
|
||||
83.3% { transform: translate(80vw, -40vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
|
||||
@keyframes dvdBounce5 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-60vw, -70vh); }
|
||||
50% { transform: translate(-90vw, -20vh); }
|
||||
75% { transform: translate(-40vw, -80vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 6 - Complex zigzag pattern */
|
||||
@keyframes dvdBounce6 {
|
||||
0% { transform: translate(0, 0); }
|
||||
14.3% { transform: translate(50vw, 30vh); }
|
||||
28.6% { transform: translate(85vw, -20vh); }
|
||||
42.9% { transform: translate(30vw, 60vh); }
|
||||
57.1% { transform: translate(70vw, 10vh); }
|
||||
71.4% { transform: translate(15vw, 70vh); }
|
||||
85.7% { transform: translate(80vw, 40vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.text-section {
|
||||
padding-left: 4rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-top: 2rem;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Login Card - White Background */
|
||||
.login-card {
|
||||
z-index: 3;
|
||||
max-width: 450px;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.white-card {
|
||||
background: white !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 20px 50px rgba(0, 0, 0, 0.15),
|
||||
0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* White Dialog Styles */
|
||||
.white-dialog {
|
||||
background: white !important;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Card Header - Dark Text */
|
||||
.welcome-title-dark {
|
||||
color: #37474F;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-instruction-dark {
|
||||
color: #546E7A;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* App Title - Dark */
|
||||
.app-title-dark {
|
||||
color: #0053AD;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.hospital-logo {
|
||||
height: 64px;
|
||||
width: auto;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.sso-text-dark {
|
||||
color: #546E7A;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.login-btn {
|
||||
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
|
||||
color: white !important;
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 8px 25px rgba(0, 83, 173, 0.3),
|
||||
0 4px 12px rgba(0, 83, 173, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
text-transform: none;
|
||||
font-size: 1rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
|
||||
box-shadow:
|
||||
0 12px 30px rgba(0, 83, 173, 0.4),
|
||||
0 6px 15px rgba(0, 83, 173, 0.3);
|
||||
}
|
||||
|
||||
.register-btn-dark {
|
||||
color: #0053AD !important;
|
||||
border: 2px solid #0053AD !important;
|
||||
background: transparent !important;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: none;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.register-btn-dark:hover {
|
||||
background: rgba(0, 83, 173, 0.05) !important;
|
||||
border-color: #0663C7 !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Custom Divider - Dark */
|
||||
.custom-divider-dark {
|
||||
border-color: rgba(0, 0, 0, 0.12) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Help Text and Links - Dark */
|
||||
.help-text-dark {
|
||||
color: #546E7A;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.contact-link-dark {
|
||||
color: #0053AD !important;
|
||||
text-decoration: underline;
|
||||
text-transform: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-link-dark {
|
||||
color: #78909C;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-link-dark:hover {
|
||||
color: #0053AD;
|
||||
}
|
||||
|
||||
/* Transparent Background */
|
||||
.transparent {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Navigation Dropdown Styles */
|
||||
.nav-dropdown {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
min-width: 220px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: #FF9B1B;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 8px;
|
||||
margin: 4px 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
|
||||
color: white;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.dropdown-item .v-icon {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover .v-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 960px) {
|
||||
.text-section {
|
||||
padding-left: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text-section {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="~/assets/styles/loginpage/login.css"></style>
|
||||
@@ -1,526 +0,0 @@
|
||||
<template>
|
||||
<!-- Main Content -->
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6 main-content-padding">
|
||||
<!-- Header Banner & Stats -->
|
||||
<v-card class="d-flex justify-space-between align-center pa-5 rounded-xl elevation-4 mb-6 header-banner">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="40" class="mr-3 text-white">mdi-hospital-box-outline</v-icon>
|
||||
<span class="text-h4 font-weight-bold text-white">Loket Admin </span>
|
||||
</div>
|
||||
<div class="d-flex align-center text-white text-end flex-wrap justify-end">
|
||||
<span class="mr-4">Loket 24</span>
|
||||
<span class="mr-4">{{ currentDateLongFormatted }}</span>
|
||||
<span>{{ currentDateShortFormatted }} - Pelayanan</span>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Status Cards Section -->
|
||||
<v-row class="mb-6">
|
||||
<!-- Panggil 1 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(1)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">1</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- Panggil 5 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(5)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">5</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- Panggil 10 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(10)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">10</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- Panggil 20 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(20)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">20</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Next Patient Card -->
|
||||
<v-card class="next-patient-card d-flex align-center justify-center pa-8 text-center rounded-xl elevation-6 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-h4 text-white">NEXT PATIENT</div>
|
||||
<div class="text-h2 font-weight-bold text-white mt-2">UM1001</div>
|
||||
<v-btn
|
||||
size="large"
|
||||
color="#00A896"
|
||||
class="mt-4 text-white"
|
||||
@click="handleNextPatientClick"
|
||||
>
|
||||
<v-icon start>mdi-arrow-right-circle</v-icon>
|
||||
Panggil Pasien Selanjutnya
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Main Data Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Data Pasien
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
class="mx-2 select-items"
|
||||
></v-select>
|
||||
<span class="mr-2 text-caption">entries</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="mainHeaders"
|
||||
:items="mainPatients"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
:row-class="getRowClass"
|
||||
class="custom-table"
|
||||
>
|
||||
<!-- Custom template for the 'aksi' column -->
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<!-- Show different buttons based on the item's status -->
|
||||
<template v-if="item.status === 'dipanggil'">
|
||||
<v-btn size="small" color="primary" variant="flat" class="rounded-lg" @click="handleProsesClick(item)">
|
||||
<v-icon start>mdi-cogs</v-icon>Proses
|
||||
</v-btn>
|
||||
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'dalam_proses'">
|
||||
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||
</v-btn>
|
||||
<v-btn size="small" color="warning" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-pause-circle-outline</v-icon>Tunda
|
||||
</v-btn>
|
||||
<v-btn size="small" color="error" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-close-circle-outline</v-icon>Batal
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn size="small" color="primary" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-cogs</v-icon>Proses
|
||||
</v-btn>
|
||||
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||
</v-btn>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Custom template for the 'panggil' column -->
|
||||
<template v-slot:item.panggil="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
:color="item.status === 'dalam_proses' ? 'grey' : 'info'"
|
||||
variant="flat"
|
||||
class="rounded-lg"
|
||||
@click="handlePanggilClick(item)"
|
||||
:disabled="item.status === 'dalam_proses'"
|
||||
>
|
||||
<v-icon start>mdi-phone</v-icon>Panggil
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- Custom template for the 'noAntrian' column -->
|
||||
<template v-slot:item.noAntrian="{ item }">
|
||||
<span :class="{'online-antrian': item.status === 'dipanggil'}">{{ item.noAntrian }}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Late Patients Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Pasien Lapor Terlambat
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption text-orange">KETERANGAN: PASIEN MASUK PADA TANGGAL</span>
|
||||
<span class="mr-2 text-caption">Show</span>
|
||||
<v-select
|
||||
v-model="lateItemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
class="mx-2 select-items"
|
||||
></v-select>
|
||||
<span class="mr-2 text-caption">entries</span>
|
||||
<v-text-field
|
||||
v-model="lateSearch"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="lateHeaders"
|
||||
:items="latePatients"
|
||||
:search="lateSearch"
|
||||
:items-per-page="lateItemsPerPage"
|
||||
class="custom-table"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Clinic Entry Patients Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Pasien Masuk Klinik
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="clinicHeaders"
|
||||
:items="clinicPatients"
|
||||
:search="clinicSearch"
|
||||
:items-per-page="clinicItemsPerPage"
|
||||
class="custom-table"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Info Klinik Table -->
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Klinik
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="infoKlinikHeaders"
|
||||
:items="infoKlinikData"
|
||||
class="custom-table"
|
||||
hide-default-footer
|
||||
disable-pagination
|
||||
>
|
||||
<template v-slot:bottom>
|
||||
<v-card-text class="d-flex justify-end text-right">
|
||||
<span class="mr-4 font-weight-bold text-h6">Total:</span>
|
||||
<span class="mr-12 font-weight-bold text-h6 text-primary">{{ totalDapatDipanggil }}</span>
|
||||
<span class="font-weight-bold text-h6 text-primary">{{ totalShiftBelumBuka }}</span>
|
||||
</v-card-text>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Reactive data
|
||||
const search = ref("");
|
||||
const lateSearch = ref("");
|
||||
const clinicSearch = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const lateItemsPerPage = ref(10);
|
||||
const clinicItemsPerPage = ref(10);
|
||||
const currentDateLongFormatted = ref("");
|
||||
const currentDateShortFormatted = ref("");
|
||||
|
||||
// Table headers
|
||||
const mainHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Jam Panggil", value: "jamPanggil" },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Panggil", align: 'center', value: "panggil", sortable: false },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const lateHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const clinicHeaders = ref([
|
||||
{ title: "#", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "No RM", value: "noRM" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const infoKlinikHeaders = ref([
|
||||
{ title: "#", value: "no" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Jumlah Shift", value: "jumlahShift" },
|
||||
{ title: "Quota Per Shift", value: "quotaPerShift" },
|
||||
{ title: "Status", value: "status" },
|
||||
{ title: "Dapat Di Panggil", value: "dapatDiPanggil" },
|
||||
{ title: "Shift Belum Buka", value: "shiftBelumBuka" },
|
||||
]);
|
||||
|
||||
// Sample data with new 'originalAntrian' and 'status' properties
|
||||
const mainPatients = ref([
|
||||
{ no: 1, jamPanggil: "11:46", barcode: "250826100362", noAntrian: "UM1002 | Online - 250826100362", originalAntrian: "UM1002", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "dipanggil" },
|
||||
{ no: 2, jamPanggil: "06:47", barcode: "250826100140", noAntrian: "UM1003", originalAntrian: "UM1003", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 3, jamPanggil: "06:47", barcode: "250826100143", noAntrian: "UM1004", originalAntrian: "UM1004", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 4, jamPanggil: "06:47", barcode: "250826100500", noAntrian: "UM1005", originalAntrian: "UM1005", shift: "Shift 1", klinik: "MATA", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 5, jamPanggil: "06:47", barcode: "250826100525", noAntrian: "UM1006", originalAntrian: "UM1006", shift: "Shift 1", klinik: "ONKOLOGI", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 6, jamPanggil: "06:47", barcode: "250826100536", noAntrian: "UM1007", originalAntrian: "UM1007", shift: "Shift 1", klinik: "THT", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
]);
|
||||
|
||||
// Tambahkan lebih banyak data pasien untuk demonstrasi "Panggil 20"
|
||||
for (let i = 7; i <= 25; i++) {
|
||||
mainPatients.value.push({
|
||||
no: i,
|
||||
jamPanggil: "07:00",
|
||||
barcode: `250826100${100 + i}`,
|
||||
noAntrian: `UM100${i}`,
|
||||
originalAntrian: `UM100${i}`,
|
||||
shift: "Shift 1",
|
||||
klinik: "UMUM",
|
||||
fastTrack: "",
|
||||
pembayaran: "UMUM",
|
||||
panggil: "Panggil",
|
||||
status: "",
|
||||
});
|
||||
}
|
||||
|
||||
const latePatients = ref([]);
|
||||
const clinicPatients = ref([]);
|
||||
const infoKlinikData = ref([
|
||||
{ no: 1, klinik: "ANESTESI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 2, klinik: "GERIATRI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 3, klinik: "GIGI DAN MULUT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 4, klinik: "HOM", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 5, klinik: "IPD", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 6, klinik: "JANTUNG", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 7, klinik: "KANDUNGAN", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 8, klinik: "KOMPLEMENTER", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBukan: "-" },
|
||||
{ no: 9, klinik: "KUL.KEL", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 10, klinik: "MATA", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 11, klinik: "ONKOLOGI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 12, klinik: "PARU", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 13, klinik: "SARAF", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 14, klinik: "THT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
]);
|
||||
|
||||
// Computed properties for totals
|
||||
const totalDapatDipanggil = computed(() => {
|
||||
return infoKlinikData.value.reduce((total, item) => {
|
||||
return total + (item.dapatDiPanggil === "-" ? 0 : parseInt(item.dapatDiPanggil));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const totalShiftBelumBuka = computed(() => {
|
||||
return infoKlinikData.value.reduce((total, item) => {
|
||||
return total + (item.shiftBelumBuka === "-" ? 0 : parseInt(item.shiftBelumBuka));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === 'dipanggil') {
|
||||
return 'called-row';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleStatusCardClick = (count) => {
|
||||
console.log(`Memanggil ${count} antrean pasien.`);
|
||||
const updatedPatients = mainPatients.value.map((patient, index) => {
|
||||
const isCalled = index < count;
|
||||
return {
|
||||
...patient,
|
||||
status: isCalled ? 'dipanggil' : '',
|
||||
noAntrian: isCalled ? `${patient.originalAntrian} | Online - ${patient.barcode}` : patient.originalAntrian
|
||||
};
|
||||
});
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
const handleNextPatientClick = () => {
|
||||
console.log("Tombol Panggil Pasien Selanjutnya diklik! (Mengambil pasien pertama dari antrean)");
|
||||
const updatedPatients = mainPatients.value.map(patient => {
|
||||
return { ...patient, status: '', noAntrian: patient.originalAntrian };
|
||||
});
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
const handlePanggilClick = (item) => {
|
||||
console.log(`Tombol Panggil untuk pasien: ${item.noAntrian} diklik!`);
|
||||
|
||||
// Membuat salinan baru dari seluruh array untuk memicu reaktivitas
|
||||
const updatedPatients = mainPatients.value.map(p => {
|
||||
// Jika pasien cocok, buat salinan baru dengan status 'dipanggil' dan tambahkan "Online"
|
||||
if (p.no === item.no) {
|
||||
return {
|
||||
...p,
|
||||
status: 'dipanggil',
|
||||
noAntrian: `${p.originalAntrian} | Online - ${p.barcode}`
|
||||
};
|
||||
}
|
||||
// Jika tidak, kembalikan objek pasien aslinya
|
||||
return p;
|
||||
});
|
||||
|
||||
// Ganti seluruh array data dengan salinan yang baru.
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
const handleProsesClick = (item) => {
|
||||
console.log(`Tombol Proses untuk pasien: ${item.noAntrian} diklik!`);
|
||||
|
||||
const updatedPatients = mainPatients.value.map(p => {
|
||||
if (p.no === item.no) {
|
||||
return {
|
||||
...p,
|
||||
status: 'dalam_proses'
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
// Mengatur tanggal saat komponen dimuat
|
||||
onMounted(() => {
|
||||
const today = new Date();
|
||||
const optionsLong = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
currentDateLongFormatted.value = today.toLocaleDateString('id-ID', optionsLong);
|
||||
|
||||
const optionsShort = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
currentDateShortFormatted.value = today.toLocaleDateString('id-ID', optionsShort);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles for a cleaner look */
|
||||
.main-content-padding {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important;
|
||||
}
|
||||
|
||||
/* Header Banner */
|
||||
.header-banner {
|
||||
background: linear-gradient(90deg, #1565C0, #1976D2);
|
||||
color: white;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Next Patient Card */
|
||||
.next-patient-card {
|
||||
background: linear-gradient(45deg, #00A896, #00796B);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status Cards */
|
||||
.v-card.text-center {
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.v-card.text-center:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.custom-table :deep(thead th) {
|
||||
background-color: #E8EAF6; /* Light gray-blue header */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||
background-color: #F5F5F5; /* Light gray for odd rows */
|
||||
}
|
||||
|
||||
/* Highlighted row for "dipanggil" status */
|
||||
.custom-table :deep(tbody tr.called-row) {
|
||||
background-color: #998479 !important; /* Light green background */
|
||||
}
|
||||
|
||||
/* Field and Select styling */
|
||||
.select-items .v-field--variant-solo,
|
||||
.v-text-field .v-field--variant-solo {
|
||||
background-color: #ECEFF1;
|
||||
}
|
||||
|
||||
.text-blue {
|
||||
color: #1976D2 !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #1976D2 !important;
|
||||
}
|
||||
|
||||
.online-antrian {
|
||||
font-weight: bold;
|
||||
color: #1976D2;
|
||||
}
|
||||
</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,113 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card>
|
||||
<v-card-title>Edit Loket</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="simpanLoket">
|
||||
<v-text-field label="Nama Loket" v-model="loket.namaLoket"></v-text-field>
|
||||
<v-text-field label="Kuota Bangku" v-model="loket.kuota" type="number"></v-text-field>
|
||||
|
||||
<v-select
|
||||
label="Status Pelayanan"
|
||||
:items="['RAWAT JALAN', 'RAWAT INAP']"
|
||||
v-model="loket.statusPelayanan"
|
||||
></v-select>
|
||||
|
||||
<v-select
|
||||
label="Pembayaran"
|
||||
:items="['JKN', 'UMUM']"
|
||||
v-model="loket.pembayaran"
|
||||
></v-select>
|
||||
|
||||
<v-select
|
||||
label="Keterangan"
|
||||
:items="['ONLINE', 'MANUAL']"
|
||||
v-model="loket.keterangan"
|
||||
></v-select>
|
||||
|
||||
<div class="my-4">
|
||||
<h3 class="text-h6">Pelayanan</h3>
|
||||
<TabelLayanan
|
||||
:headers="serviceHeaders"
|
||||
:items="availableServices"
|
||||
v-model:selected-items="loket.pelayanan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-btn color="success" type="submit" class="mr-2">Simpan</v-btn>
|
||||
<v-btn color="secondary" @click="cancelEdit">Batal</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import TabelLayanan from '../../components/TabelLayanan.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Data dummy loket yang sama seperti di 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 loket = ref({
|
||||
id: null,
|
||||
namaLoket: '',
|
||||
kuota: 0,
|
||||
statusPelayanan: '',
|
||||
pembayaran: '',
|
||||
keterangan: '',
|
||||
pelayanan: [],
|
||||
});
|
||||
|
||||
const serviceHeaders = ref([
|
||||
{ title: '#', value: 'no', sortable: false },
|
||||
{ title: 'Kode', value: 'id' },
|
||||
{ title: 'Klinik', value: 'nama' },
|
||||
{ title: 'Pilih', value: 'pilih', sortable: false },
|
||||
]);
|
||||
|
||||
const availableServices = ref([
|
||||
{ no: 1, id: 'AN', nama: 'ANAK' },
|
||||
{ no: 2, id: 'AS', nama: 'ANESTESI' },
|
||||
{ no: 3, id: 'BD', nama: 'BEDAH' },
|
||||
{ no: 4, id: 'GR', nama: 'GERIATRI' },
|
||||
{ no: 5, id: 'GI', nama: 'GIGI DAN MULUT' },
|
||||
{ no: 6, id: 'GZ', nama: 'GIZI' },
|
||||
{ no: 7, id: 'HO', nama: 'HOM' },
|
||||
{ no: 8, id: 'IP', nama: 'IPD' },
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
// Cari loket yang sesuai dengan ID di URL
|
||||
const selectedLoket = loketData.value.find(loket => loket.id === parseInt(route.params.id));
|
||||
|
||||
if (selectedLoket) {
|
||||
// Jika data ditemukan, salin ke objek loket
|
||||
loket.value = { ...selectedLoket };
|
||||
// Konversi string pelayanan menjadi array untuk checkbox
|
||||
if (typeof loket.value.pelayanan === 'string') {
|
||||
loket.value.pelayanan = loket.value.pelayanan.split(', ').map(s => s.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const simpanLoket = () => {
|
||||
// Dalam aplikasi nyata, ini adalah tempat untuk memanggil API update data
|
||||
|
||||
// Untuk simulasi, kita akan kembali ke halaman master
|
||||
router.back();
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
router.back();
|
||||
};
|
||||
</script>
|
||||
+1594
-156
File diff suppressed because it is too large
Load diff
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user