Author SHA1 Message Date
bagus-arie05 0a2453bdab update tampilan dan flow di loket dan master 2026-01-05 09:26:14 +07:00
Fanrouver b5e40c68d2 QRscan implementation 2026-01-05 08:32:59 +07:00
Fanrouver c95da96017 update user login baru dan hakakses 2025-12-18 15:11:41 +07:00
bagus-arie05 dfcd59481c update flow pasien, store & preview screen, source klinik, api dokter 2025-12-18 14:22:15 +07:00
Fanrouver d2a51f3aee update user login baru dan hakakses 2025-12-16 10:42:45 +07:00
bagus-arie05 78de0418e1 update logika dan flow anjungan, master, store 2025-12-16 09:58:25 +07:00
Fanrouver 4c2c68d993 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-12-05 10:35:30 +07:00
Fanrouver 10ef054de6 add new data 2025 05 12 2025-12-05 10:33:40 +07:00
bagus-arie05 9b3fd7b314 update card, update design system, update layout anjungan 2025-12-05 10:21:16 +07:00
bagus-arie05 817bf8f548 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-11-24 13:43:46 +07:00
bagus-arie05 82b98fdfe6 update structure component, layout page admin, konfigurasi dan layout master 2025-11-24 13:42:11 +07:00
Fanrouver 642be0f9df pull new folder 2025-11-10 09:16:04 +07:00
Fanrouver e2cf3d309a Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-11-05 13:01:37 +07:00
Fanrouver 89688e02f3 change on login sidebar and dashboard 2025-11-05 13:00:06 +07:00
bagus-arie05 56fb6b319b update dev server 2025-11-05 11:10:36 +07:00
bagus-arie05 39d064c30c update font dan color 2025-11-05 10:51:35 +07:00
Fanrouver dbb8050dc5 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-30 09:19:02 +07:00
Fanrouver 4960d4659f push update login, dashboard, check in, monitoring, profilpop, verifikasi, dll 2025-10-30 09:08:40 +07:00
Fanrouver 11d2a4671e Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-28 10:22:29 +07:00
Fanrouver 35024b4797 update akbar tampilan login dashboard dan verifikasi 2025-10-28 10:14:00 +07:00
bagus-arie05 d7f3240a82 Update flow anjungan 2025-10-21 08:31:00 +07:00
bagus-arie05 71890ffb4c Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-14 09:13:20 +07:00
bagus-arie05 31e18c5965 update sidebar dan menu layanan penunjang 2025-10-14 08:49:54 +07:00
Fanrouver 0351f9116d change branch to Akbar-antren 2025-10-13 09:25:44 +07:00
arie.bagus.2905 51809468a9 Merge pull request 'Pull request to merge from Akbar's Antrean' from Akbar-antren into Antrean-Code
Reviewed-on: arie.bagus.2905/Web-Antrean#2
2025-10-02 01:51:47 +00:00
Fanrouver 9261df87e0 push update akbar 2025-10-01 15:05:35 +07:00
Fanrouver d735824d57 akbar first commit 2025-10-01 11:28:06 +07:00
Fanrouver 55d8920a82 wednesday 20 august 2025 2025-08-20 15:03:50 +07:00
Fanrouver 81398549e2 wednesday 13 2025 progress klinik, non functionality script 2025-08-13 11:36:20 +07:00
Fanrouver 0ad29ae5fa first Commit on develop branch 2025-08-12 15:11:09 +07:00
144 changed files with 46093 additions and 9792 deletions

No files matched your search

+9
View File
@@ -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
+260
View File
@@ -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`
+61
View File
@@ -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)
+94
View File
@@ -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
+70
View File
@@ -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
+116
View File
@@ -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); }
+109
View File
@@ -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;
}
}
+165
View File
@@ -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; }
+121
View File
@@ -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;
}
}
+117
View File
@@ -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;
}
}
+882
View File
@@ -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;
}
}
+230
View File
@@ -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%);
}
+74
View File
@@ -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;
+71
View File
@@ -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;
}
}
+235
View File
@@ -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;
}
}
+457
View File
@@ -0,0 +1,457 @@
<template>
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
<span>Edit Hak Akses Menu</span>
</v-card-title>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row v-if="localItem.role || localItem.group" class="mb-4">
<v-col cols="12">
<v-alert type="info" variant="tonal" density="compact">
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
</v-alert>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
</tr>
</thead>
<tbody>
<template v-if="backendPermissions.length > 0">
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
Level {{ perm.level }}
</v-chip>
</div>
</td>
<td class="text-center kol-status">
<v-chip
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.active"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.read"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.create"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.update"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.delete"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center kol-status">
<v-chip color="success" size="small" variant="tonal">
Mapped
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="primary"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="handleSave"
:loading="isSaving"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1';
// Define types for better readability and safety
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface BackendPermissionItem {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface HakAksesData {
id: number;
role?: string;
group?: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
// Define props with type validation
const props = defineProps({
item: {
type: Object as () => HakAksesData,
required: true,
},
});
// Define emits for clarity
const emits = defineEmits(['save', 'cancel']);
// Use a local copy to avoid mutating the prop directly
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
const backendPermissions = ref<BackendPermissionItem[]>([]);
const isSaving = ref(false);
const navItemsStore = useNavItemsStore();
// Helper function to normalize group name
const normalizeGroup = (group: string): string => {
const normalized = group.trim();
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
if (normalized.toLowerCase().includes('instalasi')) {
const parts = normalized.split(/\s+/);
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
if (stimIndex !== -1) {
return 'STIM';
}
}
// Jika group adalah "Instalasi STIM", return "STIM"
if (normalized.toLowerCase() === 'instalasi stim') {
return 'STIM';
}
return normalized.toUpperCase();
};
// Helper function to normalize role name
const normalizeRole = (role: string): string => {
const normalized = role.toLowerCase().trim();
// Mapping khusus untuk role default
if (normalized === 'default-roles-sandbox') {
return 'superadmin';
}
return normalized;
};
// Fetch permissions from backend API when component mounts
onMounted(async () => {
if (localItem.value.role && localItem.value.group) {
try {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(localItem.value.role);
const normalizedGroup = normalizeGroup(localItem.value.group);
console.log('🔄 Fetching permissions with normalized values:', {
originalRole: localItem.value.role,
normalizedRole,
originalGroup: localItem.value.group,
normalizedGroup,
});
const response = await $fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
// Create a deep copy to avoid mutating the original
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
} else {
console.warn('⚠️ No data received from API or invalid structure');
}
} catch (error) {
console.error('❌ Error fetching permissions:', error);
}
} else {
console.warn('⚠️ Role or group is missing:', {
role: localItem.value.role,
group: localItem.value.group,
});
}
});
// Sort permissions: level 1 first, then level 2 grouped under their parents
const sortedPermissions = computed(() => {
if (backendPermissions.value.length === 0) return [];
const level1 = backendPermissions.value.filter(p => p.level === 1);
const level2 = backendPermissions.value.filter(p => p.level === 2);
// Sort level 1 by sort order
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Sort level 2 by sort order
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Build result: insert level 2 items after their parent
const result: BackendPermissionItem[] = [];
level1.forEach(parent => {
result.push(parent);
// Add children of this parent
const children = level2.filter(child => child.parent === parent.pagesID);
result.push(...children);
});
// Add any remaining level 2 items that don't have a parent match
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
result.push(...remainingChildren);
return result;
});
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
const isPageMapped = (pagename: string): boolean => {
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
return false;
}
const lowerPage = (pagename || '').toLowerCase();
return localItem.value.hakAksesMenu.some((menu) => {
const name = (menu.name || '').toLowerCase();
return (
name === lowerPage ||
name.includes(lowerPage) ||
lowerPage.includes(name)
);
});
};
interface NavItemOrder {
name: string;
children?: NavItemOrder[];
}
// Build a map of menu order based on the sidebar configuration so the list is always aligned
const menuOrder = computed(() => {
const order: Record<string, number> = {};
const walk = (items: NavItemOrder[], startIndex = 0): number => {
let idx = startIndex;
items.forEach((item) => {
order[item.name] = idx;
idx += 1;
if (item.children?.length) {
idx = walk(item.children, idx);
}
});
return idx;
};
walk(navItemsStore.navItems);
return order;
});
const orderedMenus = computed(() => {
const order = menuOrder.value;
return [...localItem.value.hakAksesMenu].sort((a, b) => {
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
});
// Handle save - convert backend permissions back to menu structure if needed
const handleSave = async () => {
isSaving.value = true;
try {
// If we have backend permissions, we need to map them back to the menu structure
if (backendPermissions.value.length > 0) {
// Update the local item with backend permissions mapped to menu structure
const updatedItem = {
...localItem.value,
backendPermissions: backendPermissions.value,
};
emits('save', updatedItem);
} else {
// Use existing menu structure
emits('save', localItem.value);
}
} finally {
isSaving.value = false;
}
};
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
// Re-fetch permissions if role/group changed
if (newItem.role && newItem.group) {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(newItem.role);
const normalizedGroup = normalizeGroup(newItem.group);
console.log('🔄 Re-fetching permissions with normalized values:', {
originalRole: newItem.role,
normalizedRole,
originalGroup: newItem.group,
normalizedGroup,
});
$fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
}).then(response => {
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
}
}).catch(error => {
console.error('❌ Error fetching permissions:', error);
});
}
}, { deep: true });
</script>
<style scoped>
.v-table :deep(th) {
font-weight: bold !important;
background-color: #f9fafb !important;
}
.v-table :deep(td) {
vertical-align: middle;
}
.v-checkbox :deep(.v-selection-control__input) {
color: #2196F3 !important;
}
.hak-akses-table :deep(.kol-no) {
width: 56px;
}
.hak-akses-table :deep(.kol-aksi) {
width: 90px;
}
.hak-akses-table :deep(.kol-status) {
width: 120px;
}
.cek-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
</style>
+130
View File
@@ -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>
+171
View File
@@ -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>
+148
View File
@@ -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>
+325
View File
@@ -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>
+214
View File
@@ -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>
+104
View File
@@ -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>
+179
View File
@@ -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>
+218
View File
@@ -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>
+146
View File
@@ -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>
+54
View File
@@ -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>
+55
View File
@@ -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>
+162
View File
@@ -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>
+125
View File
@@ -0,0 +1,125 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="dialogModel = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>{{ title }}</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="searchModel"
:placeholder="searchPlaceholder"
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
/>
<v-row dense>
<v-col
v-for="item in filteredItems"
:key="item.id"
cols="6"
sm="4"
>
<v-card
class="selection-card"
@click="handleSelect(item)"
elevation="0"
>
<v-card-text class="text-center pa-3">
<div class="selection-name">{{ item.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
title: {
type: String,
default: 'Pilih Item'
},
items: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
},
searchPlaceholder: {
type: String,
default: 'Cari...'
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredItems = computed(() => {
if (!searchModel.value) return props.items;
const search = searchModel.value.toLowerCase();
return props.items.filter(item =>
item.name.toLowerCase().includes(search)
);
});
const handleSelect = (item) => {
emit('select', item);
dialogModel.value = false;
};
</script>
<style scoped lang="scss">
.dialog-header {
background: var(--color-neutral-300);
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
color: var(--color-neutral-900);
}
.selection-card {
cursor: pointer;
transition: all 0.2s;
border: 2px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.selection-card:hover {
border-color: var(--color-secondary-600);
background: var(--color-secondary-200);
}
.selection-name {
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-900);
}
</style>
@@ -0,0 +1,102 @@
<template>
<v-card class="filter-card" elevation="0">
<v-card-text>
<v-row align="center">
<v-col cols="12" md="6">
<v-text-field
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
prepend-inner-icon="mdi-magnify"
label="Pencarian"
:placeholder="searchPlaceholder"
variant="outlined"
density="comfortable"
hide-details
clearable
class="search-field"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
:model-value="statusFilter"
@update:model-value="$emit('update:statusFilter', $event)"
:items="statusOptions"
label="Status"
variant="outlined"
density="comfortable"
hide-details
clearable
class="filter-select"
/>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="primary-600"
variant="outlined"
block
size="large"
@click="$emit('refresh')"
:loading="loading"
class="refresh-btn"
>
<v-icon start>mdi-refresh</v-icon>
Refresh Data
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
searchQuery: {
type: String,
default: ''
},
statusFilter: {
type: String,
default: null
},
statusOptions: {
type: Array,
default: () => ['Terdaftar', 'Belum Terdaftar', 'Proses']
},
searchPlaceholder: {
type: String,
default: 'Cari nama pasien, No. RM, atau alamat...'
},
loading: {
type: Boolean,
default: false
}
});
defineEmits(['update:searchQuery', 'update:statusFilter', 'refresh']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.filter-card {
border-radius: 12px;
border: 1px solid $neutral-500;
background: $neutral-100;
margin-bottom: 16px;
}
.search-field,
.filter-select {
font-family: $font-family-base;
}
.refresh-btn {
text-transform: none;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,231 @@
<template>
<v-dialog v-model="dialogModel" max-width="600px">
<v-card class="dialog-card">
<v-card-title class="dialog-header" :class="`dialog-header-${type}`">
<v-icon class="mr-2">{{ icon }}</v-icon>
<span>{{ title }}</span>
<v-spacer />
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<!-- patient info card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ patient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ patient.noRM }}</div>
</v-col>
</v-row>
</div>
<!-- search field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
:label="searchLabel"
variant="outlined"
density="comfortable"
hide-details
class="mb-4 search-field"
/>
<!-- options grid -->
<v-row>
<v-col
v-for="option in filteredOptions"
:key="option.id"
cols="6"
>
<v-card
class="option-card"
:class="`option-card-${type}`"
@click="$emit('select', option)"
elevation="0"
>
<v-card-text class="text-center pa-4">
<v-icon :size="32" :color="iconColor" class="mb-2">
{{ icon }}
</v-icon>
<div class="option-title">{{ option.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
type: {
type: String,
default: 'klinik', // 'klinik' | 'penunjang'
validator: (value) => ['klinik', 'penunjang'].includes(value)
},
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
patient: {
type: Object,
default: null
},
options: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
},
searchLabel: {
type: String,
default: 'Cari...'
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredOptions = computed(() => {
if (!searchModel.value) return props.options;
const search = searchModel.value.toLowerCase();
return props.options.filter(opt =>
opt.name.toLowerCase().includes(search)
);
});
const iconColor = computed(() => {
return props.type === 'klinik' ? 'secondary-600' : 'accent-600';
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$neutral-900: #212121;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$accent-600: #8B5CF6;
$accent-700: #7C3AED;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
color: $neutral-100;
padding: 20px 24px;
font-size: 18px;
font-weight: $font-weight-semibold;
display: flex;
align-items: center;
}
.dialog-header-klinik {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
}
.dialog-header-penunjang {
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
}
.close-btn {
color: $neutral-100 !important;
}
.patient-card {
background: $neutral-300;
padding: 16px;
border-radius: 12px;
border: 1px solid $neutral-500;
}
.card-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
margin-bottom: 12px;
font-family: $font-family-base;
}
.info-label {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
margin-bottom: 4px;
font-family: $font-family-base;
}
.info-value {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.search-field {
font-family: $font-family-base;
}
.option-card {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid $neutral-500;
background: $neutral-100;
}
.option-card-klinik:hover {
border-color: $secondary-600;
background: var(--color-secondary-200);
transform: translateY(-2px);
}
.option-card-penunjang:hover {
border-color: $accent-600;
background: var(--color-accent-200);
transform: translateY(-2px);
}
.option-title {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,262 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px">
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<v-icon class="mr-2">mdi-door-open</v-icon>
<span>Pilih Klinik Ruang</span>
<v-spacer />
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<!-- Patient Info Card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ patient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ patient.noRM }}</div>
</v-col>
</v-row>
</div>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
label="Cari Klinik Ruang"
variant="outlined"
density="comfortable"
hide-details
class="mb-4 search-field"
/>
<!-- Ruang List with Expansion Panels -->
<v-expansion-panels class="ruang-panels">
<v-expansion-panel
v-for="klinikRuang in filteredRuang"
:key="klinikRuang.id"
class="panel-item"
>
<v-expansion-panel-title class="panel-title">
<div class="d-flex align-center w-100">
<v-chip size="small" color="primary-600" class="mr-2 kode-chip">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<strong class="klinik-name">{{ klinikRuang.namaKlinik }}</strong>
<v-spacer />
<v-chip size="small" variant="outlined" class="count-chip">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list density="compact" class="ruang-list">
<v-list-item
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
@click="$emit('select', klinikRuang, ruang)"
class="ruang-item"
>
<template #prepend>
<v-icon color="primary-600" size="20">mdi-door</v-icon>
</template>
<v-list-item-title class="ruang-title">
<strong>Ruang {{ ruang.nomorRuang }}</strong> - {{ ruang.namaRuang }}
</v-list-item-title>
<v-list-item-subtitle class="ruang-subtitle">
Screen: {{ ruang.nomorScreen }}
</v-list-item-subtitle>
<template #append>
<v-icon size="20">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
patient: {
type: Object,
default: null
},
ruangList: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredRuang = computed(() => {
if (!searchModel.value) return props.ruangList;
const search = searchModel.value.toLowerCase();
return props.ruangList.filter(k =>
k.namaKlinik.toLowerCase().includes(search) ||
k.kodeKlinik.toLowerCase().includes(search)
);
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
font-size: 18px;
font-weight: $font-weight-semibold;
display: flex;
align-items: center;
}
.close-btn {
color: $neutral-100 !important;
}
.patient-card {
background: $neutral-300;
padding: 16px;
border-radius: 12px;
border: 1px solid $neutral-500;
}
.card-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
margin-bottom: 12px;
font-family: $font-family-base;
}
.info-label {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
margin-bottom: 4px;
font-family: $font-family-base;
}
.info-value {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.search-field {
font-family: $font-family-base;
}
.ruang-panels {
font-family: $font-family-base;
}
.panel-item {
margin-bottom: 8px;
border: 1px solid $neutral-500;
border-radius: 8px;
}
.panel-title {
font-family: $font-family-base;
}
.kode-chip {
font-weight: $font-weight-semibold;
font-size: 12px;
}
.klinik-name {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.count-chip {
font-size: 12px;
font-family: $font-family-base;
}
.ruang-list {
padding: 0;
}
.ruang-item {
cursor: pointer;
border-radius: 8px;
margin-bottom: 4px;
transition: background 0.2s;
font-family: $font-family-base;
}
.ruang-item:hover {
background: $neutral-300;
}
.ruang-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.ruang-subtitle {
font-size: 12px;
color: $neutral-700;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,195 @@
<template>
<v-card elevation="0" class="patient-table-card">
<v-card-title class="table-title">
<v-icon class="mr-2" color="primary-600">mdi-account-group</v-icon>
<span>Daftar Pasien - {{ items.length }} pasien</span>
</v-card-title>
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="items"
:search="searchQuery"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
density="comfortable"
>
<template #item.namaPasien="{ item }">
<div class="patient-info">
<div class="patient-name">{{ item.namaPasien }}</div>
<div class="patient-meta">{{ item.noRM }}</div>
</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
class="status-chip"
>
{{ item.status }}
</v-chip>
</template>
<template #item.buatAntrean="{ item }">
<div class="action-buttons">
<v-btn
size="small"
color="secondary-600"
variant="outlined"
@click="$emit('create-klinik', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-hospital-building</v-icon>
Klinik
</v-btn>
<v-btn
size="small"
color="primary-600"
variant="outlined"
@click="$emit('create-ruang', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-door-open</v-icon>
Klinik Ruang
</v-btn>
<v-btn
size="small"
color="accent-600"
variant="outlined"
@click="$emit('create-penunjang', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-clipboard-pulse</v-icon>
Penunjang
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
items: {
type: Array,
required: true
},
searchQuery: {
type: String,
default: ''
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'Nama Pasien', value: 'namaPasien', sortable: true },
{ title: 'No. RM', value: 'noRM', sortable: true },
{ title: 'Alamat', value: 'alamat', sortable: true },
{ title: 'Status', value: 'status', sortable: true },
{ title: 'Buat Antrean', value: 'buatAntrean', sortable: false, width: '360px' },
];
defineEmits(['create-klinik', 'create-ruang', 'create-penunjang']);
const getStatusColor = (status) => {
const colors = {
'Terdaftar': 'var(--color-success-600)',
'Belum Terdaftar': 'var(--color-primary-600)',
'Proses': 'var(--color-secondary-600)'
};
return colors[status] || 'var(--color-neutral-600)';
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.patient-table-card {
border-radius: 12px;
border: 1px solid $neutral-500;
background: $neutral-100;
}
.table-title {
display: flex;
align-items: center;
padding: 16px 20px;
background: var(--color-neutral-300);
font-size: 16px;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
color: $neutral-900;
}
.patient-info {
padding: 4px 0;
}
.patient-name {
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
color: $neutral-900;
font-family: $font-family-base;
}
.patient-meta {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
font-family: $font-family-base;
}
.status-chip {
font-weight: $font-weight-semibold;
font-size: 11px;
font-family: $font-family-base;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.action-btn {
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 12px;
font-family: $font-family-base;
}
:deep(.data-table) {
font-family: $font-family-base;
}
:deep(.data-table th) {
font-family: $font-family-base;
font-weight: $font-weight-semibold !important;
color: $neutral-600 !important;
}
:deep(.data-table td) {
font-family: $font-family-base;
border-bottom: 1px solid $neutral-400 !important;
}
:deep(.data-table tbody tr:hover) {
background: var(--color-neutral-300) !important;
}
</style>
@@ -0,0 +1,83 @@
<template>
<div class="day-selector">
<v-chip
v-for="hari in dayList"
:key="hari.hari"
class="day-chip"
:class="{ 'day-chip-active': selectedDays.includes(hari.hari) }"
label
@click="toggleDay(hari.hari)"
>
<v-icon v-if="selectedDays.includes(hari.hari)" left size="16">mdi-check</v-icon>
{{ hari.hari }}
</v-chip>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const dayList = ref([
{ no: 1, hari: 'Senin' },
{ no: 2, hari: 'Selasa' },
{ no: 3, hari: 'Rabu' },
{ no: 4, hari: 'Kamis' },
{ no: 5, hari: 'Jum\'at' },
]);
const selectedDays = ref(props.modelValue);
const toggleDay = (hari) => {
const index = selectedDays.value.indexOf(hari);
if (index > -1) {
selectedDays.value.splice(index, 1);
} else {
selectedDays.value.push(hari);
}
emit('update:modelValue', selectedDays.value);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$neutral-800: #4D4D4D;
$secondary-300: #DBEDFF;
$secondary-600: #0671E0;
$font-weight-medium: 500;
.day-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.day-chip {
cursor: pointer;
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-medium;
border: 1px solid $neutral-500;
background-color: $neutral-100;
color: $neutral-800;
&:hover {
background-color: $secondary-300;
}
}
.day-chip-active {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
border-color: $secondary-600 !important;
}
</style>
@@ -0,0 +1,276 @@
<template>
<v-dialog v-model="dialogModel" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Klinik' : 'Tambah Klinik' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Dasar -->
<FormFieldGroup title="Informasi Dasar">
<v-row dense>
<v-col cols="4">
<v-text-field
v-model="formModel.kode"
label="Kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode harus diisi']"
hide-details="auto"
placeholder="AN"
class="mb-3 input-field"
/>
<small class="caption-2">2 Huruf</small>
</v-col>
<v-col cols="4">
<v-text-field
v-model="formModel.nama"
label="Nama"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama harus diisi']"
hide-details="auto"
placeholder="Gigi dan Mulut"
class="mb-3 input-field"
/>
</v-col>
<v-col cols="4">
<v-text-field
v-model.number="formModel.shift"
label="Jumlah Shift"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Shift harus diisi', v => v > 0 || 'Minimal 1']"
hide-details="auto"
class="mb-3 input-field"
@update:model-value="updateShiftCount"
/>
</v-col>
</v-row>
<v-checkbox
v-model="formModel.autoShift"
label="Auto Shift"
hide-details
color="#0671E0"
density="compact"
class="checkbox-field"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi Shift & Kuota -->
<FormFieldGroup title="Konfigurasi Shift & Kuota" icon="mdi-clock-outline">
<ShiftConfiguration
:shifts="formModel.jamShiftList"
@update:shifts="formModel.jamShiftList = $event"
@remove-shift="removeShift"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Jadwal Klinik -->
<FormFieldGroup title="Jadwal Klinik" icon="mdi-calendar-check">
<DaySelector v-model="formModel.jadwalKlinik" />
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="handleClose"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="handleSubmit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
import ShiftConfiguration from '@/components/master/ShiftConfiguration.vue';
import DaySelector from '@/components/master/DaySelector.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const updateShiftCount = (newShiftCount) => {
const currentCount = formModel.value.jamShiftList.length;
if (newShiftCount > currentCount) {
for (let i = currentCount; i < newShiftCount; i++) {
let defaultDari = '07:00', defaultSampai = '11:00';
if (i === 1) { defaultDari = '13:00'; defaultSampai = '16:00'; }
else if (i === 2) { defaultDari = '18:00'; defaultSampai = '20:00'; }
else if (i > 2) {
const prevShift = formModel.value.jamShiftList[i - 1];
const [prevHour] = prevShift.sampai.split(':');
const nextHour = (parseInt(prevHour) + 1) % 24;
defaultDari = `${String(nextHour).padStart(2, '0')}:00`;
defaultSampai = `${String((nextHour + 4) % 24).padStart(2, '0')}:00`;
}
formModel.value.jamShiftList.push({ dari: defaultDari, sampai: defaultSampai, kuota: 0 });
}
} else if (newShiftCount < currentCount && newShiftCount > 0) {
formModel.value.jamShiftList = formModel.value.jamShiftList.slice(0, newShiftCount);
}
};
const removeShift = (index) => {
if (formModel.value.jamShiftList.length > 1) {
formModel.value.jamShiftList.splice(index, 1);
formModel.value.shift = formModel.value.jamShiftList.length;
}
};
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (valid) {
emit('submit', formModel.value);
}
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.checkbox-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,171 @@
<template>
<div>
<div v-for="(shift, index) in shifts" :key="index" class="shift-item">
<v-row dense align="center">
<v-col cols="2">
<div class="shift-badge body-3">Shift {{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
v-model="shift.dari"
label="Mulai"
type="time"
variant="outlined"
density="compact"
hide-details
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model="shift.sampai"
label="Selesai"
type="time"
variant="outlined"
density="compact"
hide-details
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model.number="shift.kuota"
label="Kuota"
type="number"
variant="outlined"
density="compact"
hide-details
placeholder="0"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="1">
<v-btn
v-if="shifts.length > 1"
icon
size="small"
variant="text"
class="btn-delete-shift"
@click="removeShift(index)"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<div class="total-badge">
<v-icon size="18" class="icon-success">mdi-sigma</v-icon>
<span class="body-3 text-medium">Total Kuota:</span>
<v-chip size="small" variant="flat" class="chip-success">
{{ totalQuota }}
</v-chip>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
shifts: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:shifts', 'remove-shift']);
const totalQuota = computed(() => {
return props.shifts.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
});
const emitUpdate = () => {
emit('update:shifts', props.shifts);
};
const removeShift = (index) => {
emit('remove-shift', index);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$success-200: #F1FBF8;
$success-300: #84DFC1;
$success-600: #009262;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.shift-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.shift-badge {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-delete-shift {
color: $danger-600 !important;
}
.total-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: $success-200;
border-radius: 8px;
margin-top: 8px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.chip-success {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
}
</style>
@@ -0,0 +1,111 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template #item.shift="{ item }">
<v-chip size="small" class="chip-secondary">
{{ item.shift }}
</v-chip>
</template>
<template #item.totalQuota="{ item }">
<span class="body-2 text-semibold">{{ item.totalQuota }}</span>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
class="btn-edit mr-2"
variant="flat"
@click="$emit('edit', item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
class="btn-delete"
variant="outlined"
@click="$emit('delete', item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'No', value: 'no', sortable: true },
{ title: 'Kode', value: 'kode', sortable: true },
{ title: 'Nama Klinik', value: 'nama', sortable: true },
{ title: 'Shift', value: 'shift', sortable: true },
{ title: 'Total Kuota', value: 'totalQuota', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-800: #4D4D4D;
$primary-600: #FFA532;
$secondary-600: #0671E0;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-secondary {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.body-2 {
font-size: 16px;
line-height: 24px;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,327 @@
<template>
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Loket' : 'Tambah Loket Baru' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Loket -->
<FormFieldGroup title="Informasi Loket">
<v-text-field
v-model="formModel.namaLoket"
label="Nama Loket"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama loket harus diisi']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Loket 1"
/>
<v-text-field
v-model.number="formModel.kuota"
label="Kuota Bangku"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota minimal 1']"
hide-details="auto"
class="mb-3 input-field"
placeholder="500"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi -->
<FormFieldGroup title="Konfigurasi">
<v-select
v-model="formModel.statusPelayanan"
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
hide-details="auto"
class="mb-3 input-field"
/>
<v-row dense>
<v-col cols="6">
<v-select
v-model="formModel.pembayaran"
label="Pembayaran"
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Pembayaran harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="formModel.keterangan"
label="Keterangan"
:items="['ONLINE', 'OFFLINE']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Keterangan harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
</v-row>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Pelayanan -->
<FormFieldGroup title="Pelayanan" icon="mdi-hospital-box">
<v-select
v-model="formModel.pelayanan"
label="Pilih Pelayanan"
:items="availableServices"
item-title="nama"
item-value="id"
variant="outlined"
density="compact"
multiple
chips
closable-chips
:rules="[v => v.length > 0 || 'Pilih minimal 1 pelayanan']"
hide-details="auto"
class="input-field"
>
<template #chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
class="chip-primary"
>
{{ item.raw.nama }}
</v-chip>
</template>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<v-chip size="x-small" class="chip-primary-small">{{ item.raw.id }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<!-- Selected Services Preview -->
<div v-if="formModel.pelayanan.length > 0" class="selected-preview">
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
<small class="caption-2">{{ formModel.pelayanan.length }} pelayanan dipilih</small>
</div>
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="handleClose"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="handleSubmit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
},
availableServices: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (valid) {
emit('submit', formModel.value);
}
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$success-200: #F1FBF8;
$success-300: #84DFC1;
$success-600: #009262;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
color: $neutral-700;
}
.chip-primary {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-primary-small {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
.selected-preview {
margin-top: 8px;
padding: 8px 12px;
background: $success-200;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,125 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template #item.pelayanan="{ item }">
<v-chip
v-for="(serviceKode, idx) in item.pelayanan.slice(0, 2)"
:key="idx"
size="small"
class="mr-1 mb-1 chip-primary-outline"
>
{{ getKlinikName(serviceKode) }}
</v-chip>
<v-chip
v-if="item.pelayanan.length > 2"
size="small"
class="chip-neutral"
>
+{{ item.pelayanan.length - 2 }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
variant="flat"
class="btn-edit mr-2"
@click="$emit('edit', item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
variant="outlined"
class="btn-delete"
@click="$emit('delete', item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
},
getKlinikName: {
type: Function,
required: true
}
});
const headers = [
{ title: "No", value: "no" },
{ title: "Nama Loket", value: "namaLoket" },
{ title: "Kuota", value: "kuota" },
{ title: "Pelayanan", value: "pelayanan" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Keterangan", value: "keterangan" },
{ title: "Aksi", value: "aksi", sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-600: #89939E;
$primary-600: #FFA532;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-primary-outline {
border: 1px solid $primary-600;
background-color: transparent !important;
color: $primary-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.chip-neutral {
background-color: $neutral-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,245 @@
<template>
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Klinik Ruang' : 'Tambah Klinik Ruang' }}</span>
<v-btn icon variant="text" @click="handleClose" size="small" class="btn-close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Pilih Klinik -->
<FormFieldGroup title="Pilih Klinik" icon="mdi-hospital-building">
<v-autocomplete
label="Kode Klinik"
v-model="formModel.kodeKlinik"
:items="klinikList"
item-title="nama"
item-value="kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode klinik harus dipilih']"
hide-details="auto"
placeholder="Pilih Klinik"
@update:model-value="handleKlinikChange"
class="mb-3 input-field"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-chip size="x-small" class="chip-success-small">{{ item.raw.kode }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-autocomplete>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Daftar Ruangan -->
<FormFieldGroup title="Daftar Ruangan" icon="mdi-door">
<RoomListManager
:rooms="formModel.ruangList"
@update:rooms="formModel.ruangList = $event"
@add-room="addRoom"
@remove-room="removeRoom"
/>
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
@click="handleClose"
class="btn-cancel"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
@click="handleSubmit"
class="btn-submit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
import RoomListManager from '@/components/master/RoomListManager.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
},
klinikList: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close', 'klinik-change']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const handleKlinikChange = (kode) => {
emit('klinik-change', kode);
};
const addRoom = () => {
formModel.value.ruangList.push({
nomorRuang: '',
namaRuang: '',
nomorScreen: ''
});
};
const removeRoom = (index) => {
if (formModel.value.ruangList.length > 1) {
formModel.value.ruangList.splice(index, 1);
}
};
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
if (formModel.value.ruangList.length === 0) {
// Emit validation error
emit('validation-error', 'Tambahkan minimal 1 ruangan');
return;
}
emit('submit', formModel.value);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$success-600: #009262;
$success-700: #1B6E53;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.chip-success-small {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<div>
<div v-for="(ruang, index) in rooms" :key="index" class="ruang-item">
<v-row dense align="center">
<v-col cols="1">
<div class="ruang-badge body-3">{{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Ruang"
v-model="ruang.nomorRuang"
variant="outlined"
density="compact"
hide-details
placeholder="1"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="4">
<v-text-field
label="Nama Ruang"
v-model="ruang.namaRuang"
variant="outlined"
density="compact"
hide-details
placeholder="Ruang Konsultasi"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Screen"
v-model="ruang.nomorScreen"
variant="outlined"
density="compact"
hide-details
placeholder="101"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="1">
<v-btn
v-if="rooms.length > 1"
icon
size="small"
variant="text"
@click="removeRoom(index)"
class="btn-delete-ruang"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<v-btn
variant="outlined"
size="small"
@click="addRoom"
class="btn-add-ruang mt-2"
>
<v-icon left size="18">mdi-plus</v-icon>
Tambah Ruang
</v-btn>
</div>
</template>
<script setup>
const props = defineProps({
rooms: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:rooms', 'add-room', 'remove-room']);
const emitUpdate = () => {
emit('update:rooms', props.rooms);
};
const addRoom = () => {
emit('add-room');
};
const removeRoom = (index) => {
emit('remove-room', index);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$success-600: #009262;
$success-700: #1B6E53;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.ruang-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.ruang-badge {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-delete-ruang {
color: $danger-600 !important;
}
.btn-add-ruang {
border-color: $success-600 !important;
color: $success-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,108 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template v-slot:item.namaKlinik="{ item }">
<v-chip size="small" class="chip-success-outline">
{{ item.kodeKlinik }}
</v-chip>
<span class="ml-2 body-3 text-medium">{{ item.namaKlinik }}</span>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
@click="$emit('edit', item)"
class="btn-edit mr-2"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
@click="$emit('delete', item)"
class="btn-delete"
variant="outlined"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'No', value: 'no', sortable: true },
{ title: 'Nama Klinik', value: 'namaKlinik', sortable: true },
{ title: 'Kode', value: 'kodeKlinik', sortable: true },
{ title: 'Nama Ruang', value: 'namaRuang', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$success-600: #009262;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-success-outline {
border: 1px solid $success-600;
background-color: transparent !important;
color: $success-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.btn-edit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,168 @@
<template>
<v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">SEDANG DIPROSES</div>
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
<div class="patient-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div>
<div class="patient-info-text mb-3">
<div>{{ patient.barcode }}</div>
<div>{{ patient.klinik }} | {{ patient.pembayaran }}</div>
</div>
<div class="action-grid">
<v-btn class="text-white" color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')">
<v-icon start size="20">mdi-check</v-icon>
Selesai
</v-btn>
<v-btn class="text-white" color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')">
<v-icon start size="20">mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn class="text-white" color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')">
<v-icon start size="20">mdi-pause</v-icon>
Pending
</v-btn>
<v-btn class="text-white" color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')">
<v-icon start size="20">mdi-swap-horizontal</v-icon>
{{ changeButtonText }}
</v-btn>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="48" color="grey-lighten-2">mdi-account-off-outline</v-icon>
<div class="empty-text mb-4">Tidak ada pasien yang diproses</div>
<v-btn
block
class="py-6 text-white"
color="primary-600"
size="large"
:disabled="!hasNextQueue"
@click="$emit('process-next')"
>
<v-icon start>mdi-play-circle</v-icon>
Proses Antrian Berikutnya
</v-btn>
<div v-if="nextQueueInfo" class="next-queue-info mt-3">
<div class="info-text">{{ nextQueueInfo }}</div>
</div>
<div v-else-if="!hasNextQueue" class="no-next-queue mt-3">
<div class="info-text">Tidak ada antrian di loket</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
patient: {
type: Object,
default: null
},
theme: {
type: String,
default: 'primary', // 'primary', 'secondary', or 'accent'
validator: (value) => ['primary', 'secondary', 'accent'].includes(value)
},
changeButtonText: {
type: String,
default: 'Ubah Klinik'
},
hasNextQueue: {
type: Boolean,
default: false
},
nextQueueInfo: {
type: String,
default: null
}
});
defineEmits(['action', 'change-klinik', 'process-next']);
</script>
<style scoped lang="scss">
.current-patient-card {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.patient-details {
border-radius: 8px;
padding: 16px;
}
.patient-details-primary {
background: linear-gradient(135deg, var(--color-primary-200) 0%, var(--color-primary-300) 100%);
}
.patient-details-secondary {
background: linear-gradient(135deg, var(--color-secondary-200) 0%, var(--color-secondary-300) 100%);
}
.patient-details-accent {
background: linear-gradient(135deg, var(--color-accent-200) 0%, var(--color-accent-300) 100%);
}
.patient-number {
font-size: 28px;
font-weight: 800;
color: var(--color-neutral-900);
line-height: 1;
}
.patient-info-text {
font-size: 13px;
color: var(--color-neutral-700);
line-height: 1.5;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.empty-state {
text-align: center;
padding: 32px 16px;
}
.empty-text {
font-size: 13px;
color: var(--color-neutral-600);
margin-top: 8px;
}
.next-queue-info,
.no-next-queue {
text-align: center;
padding: 8px;
background: var(--color-neutral-200);
border-radius: 8px;
}
.info-text {
font-size: 12px;
color: var(--color-neutral-700);
font-weight: 600;
}
@media (max-width: 960px) {
.action-grid {
grid-template-columns: 1fr;
}
}
</style>
+241
View File
@@ -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>
+166
View File
@@ -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>
+376
View File
@@ -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>
+180
View File
@@ -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>
+135
View File
@@ -0,0 +1,135 @@
// composables/useAuth.ts - Enhanced version with better error handling
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
export const useAuth = () => {
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
const clearError = () => {
error.value = null
}
const checkAuth = async (): Promise<User | null> => {
try {
isLoading.value = true
clearError()
const response = await $fetch<SessionResponse>('/api/auth/session')
if (response.success === false && response.error) {
error.value = response.error
user.value = null
return null
}
user.value = response.user
return response.user
} catch (fetchError: any) {
console.error('Session check failed:', fetchError)
error.value = 'Failed to check authentication status'
user.value = null
return null
} finally {
isLoading.value = false
}
}
const login = async (): Promise<void> => {
try {
clearError()
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('🔗 Redirecting to Keycloak login...')
window.location.href = response.data.authUrl
} else {
const errorMsg = response?.error || 'Failed to get authorization URL'
error.value = errorMsg
throw new Error(errorMsg)
}
} catch (loginError: any) {
console.error('❌ Login error:', loginError)
error.value = loginError.message || 'Login failed'
throw loginError
}
}
const logout = async (): Promise<void> => {
try {
isLoading.value = true
clearError()
console.log('🚪 Starting logout process...')
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
method: 'POST'
})
// Clear user immediately regardless of response
user.value = null
if (response?.success && response?.logoutUrl) {
console.log('🔗 Redirecting to Keycloak logout...')
window.location.href = response.logoutUrl
} else {
const warningMsg = response?.error || response?.message || 'No logout URL received'
console.warn('⚠️', warningMsg)
error.value = warningMsg
await navigateTo('/LoginPage')
}
} catch (logoutError: any) {
console.error('❌ Logout error:', logoutError)
error.value = logoutError.message || 'Logout failed'
user.value = null
await navigateTo('/LoginPage')
} finally {
isLoading.value = false
}
}
// Helper function to refresh user data
const refreshUser = async (): Promise<boolean> => {
const userData = await checkAuth()
return !!userData
}
// Helper function to check if user has specific role
const hasRole = (role: string): boolean => {
if (!user.value) return false
// Check in roles array
if (user.value.roles?.includes(role)) return true
// Check in realm_access.roles
if (user.value.realm_access?.roles?.includes(role)) return true
return false
}
// Helper function to check if user has any of the specified roles
const hasAnyRole = (roles: string[]): boolean => {
return roles.some(role => hasRole(role))
}
return {
// State
user: readonly(user),
isAuthenticated,
isLoading: readonly(isLoading),
error: readonly(error),
// Actions
checkAuth,
login,
logout,
refreshUser,
clearError,
// Utilities
hasRole,
hasAnyRole
}
}
+203
View File
@@ -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,
}
}
+276
View File
@@ -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 }>,
}
}
+36
View File
@@ -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 };
};
+211
View File
@@ -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,
}
}
+574
View File
@@ -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,
}
}
+208
View File
@@ -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,
};
};
+44
View File
@@ -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,
}
}
+23
View File
@@ -0,0 +1,23 @@
export const PRIMARY_COLOR = '#1565C0'
export const SECONDARY_COLOR = '#FB8C00'
export const QR_CODE_ID = 'qr-reader'
export const SCAN_DEBOUNCE_MS = 2000 // 2 detik debounce untuk mencegah scan berulang
export const HISTORY_STORAGE_KEY = 'checkin_history'
export const SCANNED_QR_STORAGE_KEY = 'scanned_qr_data'
export const SUCCESSFUL_SCANS_KEY = 'successful_qr_scans'
export const MAX_HISTORY_ITEMS = 100
export const MAX_SCANNED_QR_ITEMS = 50
export const HISTORY_STATUS_OPTIONS = [
{ title: 'Berhasil', value: 'success' },
{ title: 'Gagal', value: 'failed' },
{ title: 'Pending', value: 'pending' }
] as const
export const QR_STATUS_OPTIONS = [
{ title: 'Diizinkan Check-in', value: 'ALLOWED' },
{ title: 'Belum Diizinkan', value: 'NOT_ALLOWED' }
] as const
+234 -281
View File
@@ -1,299 +1,252 @@
<template>
<v-app>
<!-- Navigation Drawer -->
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
app
color="white"
width="280"
rail-width="72"
>
<!-- Header Sidebar -->
<div class="pa-4 d-flex align-center" style="height: 64px;">
<v-icon color="#ff9248" size="32">mdi-hospital-building</v-icon>
<v-toolbar-title v-show="!rail" class="ml-3 text-h6">
Antrean RSSA
</v-toolbar-title>
</div>
<v-app id="inspire">
<SideBar
:items="filteredNavItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<v-divider></v-divider>
<!-- Menu Items -->
<v-list density="default" nav class="py-2">
<v-list-subheader v-show="!rail" class="text-caption text-grey">
OVERVIEW
</v-list-subheader>
<template v-for="item in items" :key="item.title">
<!-- Item dengan Children -->
<v-menu
v-if="item.children"
open-on-hover
location="end"
:disabled="!rail"
>
<template v-slot:activator="{ props }">
<v-list-group v-if="!rail" :value="item.title">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.title"
:active="item.title === currentActiveMenu"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</template>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
:title="child.title"
:active="child.to === currentRoute.path"
rounded="lg"
class="mx-2 my-1 pl-12"
>
</v-list-item>
</v-list-group>
<!-- Rail mode menu -->
<v-list-item
v-else
v-bind="props"
:prepend-icon="item.icon"
:active="item.title === currentActiveMenu"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</template>
<!-- Submenu untuk rail mode -->
<v-list class="py-2" style="min-width: 200px;">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ item.title }}
</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
:title="child.title"
:active="child.to === currentRoute.path"
rounded="lg"
class="mx-2"
>
</v-list-item>
</v-list>
</v-menu>
<!-- Item tanpa Children -->
<v-list-item
v-else
:prepend-icon="item.icon"
:title="!rail ? item.title : ''"
:to="item.to"
:active="item.to === currentRoute.path"
rounded="lg"
class="mx-2 my-1"
>
<template v-slot:append v-if="item.badge && !rail">
<v-chip size="x-small" color="primary">{{ item.badge }}</v-chip>
</template>
</v-list-item>
</template>
<v-divider class="my-4"></v-divider>
<v-list-subheader v-show="!rail" class="text-caption text-grey">
ACCOUNT
</v-list-subheader>
<v-list-item
prepend-icon="mdi-cog-outline"
:title="!rail ? 'Settings' : ''"
to="/settings"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
<v-list-item
prepend-icon="mdi-logout"
:title="!rail ? 'Log out' : ''"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</v-list>
<template v-slot:append>
<!-- Theme Toggle -->
<div class="pa-4 d-flex justify-center align-center">
<v-icon color="primary" size="20">mdi-white-balance-sunny</v-icon>
<v-switch
v-show="!rail"
v-model="darkMode"
hide-details
density="compact"
color="primary"
class="mx-2"
></v-switch>
<v-icon v-show="!rail" size="20">mdi-moon-waning-crescent</v-icon>
</div>
<v-divider></v-divider>
<!-- User Profile -->
<div class="pa-4">
<div class="d-flex align-center">
<v-avatar color="#ff9248" size="40">
<span class="text-white">AS</span>
</v-avatar>
<div v-show="!rail" class="ml-3 flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">Adam Sulfat</div>
<div class="text-caption text-grey">adam@rssa.com</div>
</div>
<v-btn
v-show="!rail"
icon
size="small"
variant="text"
>
<v-icon size="20">mdi-dots-vertical</v-icon>
</v-btn>
</div>
</div>
</template>
</v-navigation-drawer>
<!-- Main Content -->
<v-main>
<!-- Top Bar untuk Toggle -->
<v-app-bar flat color="transparent" height="64">
<v-btn
icon
@click="rail = !rail"
class="ml-2"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-btn icon>
<v-icon>mdi-bell-outline</v-icon>
</v-btn>
</v-app-bar>
<v-container fluid class="pa-6">
<slot></slot>
</v-container>
<v-main app>
<slot />
</v-main>
<!-- Footer -->
<v-footer app class="bg-grey-lighten-4">
<v-container fluid class="py-2">
<v-row no-gutters align="center">
<v-col cols="12" md="6">
<span class="text-caption text-grey-darken-2">
RSUD Dr. Saiful Anwar Malang | Jl. Jaksa Agung Suprapto No. 2 Malang | Telp: 0341-362101
</span>
</v-col>
<v-col cols="12" md="6" class="text-right">
<span class="text-caption text-grey-darken-2">
ITIKom Antrian RSSA Ver. 0.2
</span>
</v-col>
</v-row>
</v-container>
</v-footer>
</v-app>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRoute } from 'vue-router';
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useLocalStorage } from "@vueuse/core";
import SideBar from "../components/layout/SideBar.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth";
// State
definePageMeta({
middleware: ['auth', 'permissions']
})
// State for controlling the sidebar
const drawer = ref(true);
const rail = ref(false);
const darkMode = ref(false);
const rail = ref(true);
// Menu Items
const items = ref([
{ title: "Dashboard", icon: "mdi-view-dashboard-outline", to: "/dashboard" },
// { title: "Marketplace", icon: "mdi-storefront-outline", to: "/marketplace" },
// { title: "My Properties", icon: "mdi-home-outline", to: "/properties" },
// { title: "Auctions", icon: "mdi-gavel", to: "/auctions" },
// { title: "Wallet", icon: "mdi-wallet-outline", to: "/wallet" },
// { title: "Favorites", icon: "mdi-heart-outline", to: "/favorites" },
{
title: "Setting",
icon: "mdi-cog-outline",
children: [
{ title: "Hak Akses", to: "/setting/hak-akses" },
{ title: "User Login", to: "/setting/user-login" },
{ title: "Master Loket", to: "/setting/master-loket" },
{ title: "Master Klinik", to: "/setting/master-klinik" },
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
{ title: "Screen", to: "/setting/screen" },
],
},
{ title: "Loket Admin", icon: "mdi-account-supervisor-outline", to: "/loket-admin" },
{ title: "Ranap Admin", icon: "mdi-bed-outline", to: "/ranap-admin" },
{
title: "Anjungan",
icon: "mdi-account-box-multiple-outline",
children: [
{ title: "Anjungan", to: "/anjungan/anjungan" },
{ title: "Klinik Ruang", to: "/anjungan/AntrianKlinik"},
],
},
{ title: "Data Pasien", icon: "mdi-account-multiple-outline", to: "/data-pasien" },
]);
const navItemsStore = useNavItemsStore();
const { user, checkAuth } = useAuth();
const currentRoute = useRoute();
const currentActiveMenu = computed(() => {
const currentItem = items.value.find(item => item.to === currentRoute.path);
if (currentItem) {
return currentItem.title;
}
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
for (const item of items.value) {
if (item.children) {
const childItem = item.children.find(child => child.to === currentRoute.path);
if (childItem) {
return item.title;
interface BackendPermission {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface PermissionResponse {
message?: string;
data?: BackendPermission[];
meta?: {
count: number;
total: number;
};
error?: string;
}
// Cache for API permissions
const apiPermissions = ref<BackendPermission[]>([]);
const currentUserRoles = ref<string[]>([]);
const currentUserGroups = ref<string[]>([]);
// Get current user data with roles and groups
const fetchCurrentUserData = async () => {
try {
const userData = await $fetch('/api/users/current');
currentUserRoles.value = [
...(userData.realmRoles || []),
...(userData.roles || []),
];
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const groups: string[] = [];
(userData.groups || []).forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]); // Get second part as group name
} else if (parts.length === 1) {
groups.push(parts[0]);
}
}
});
currentUserGroups.value = groups;
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
} catch (error) {
console.error('Error fetching current user data:', error);
return { roles: [], groups: [] };
}
return '';
};
// Fetch permissions from backend API (only for nav filtering, not for saving)
// Saving is now handled by permissions middleware
const fetchPermissionsFromAPI = async () => {
const { roles, groups } = await fetchCurrentUserData();
if (roles.length === 0 || groups.length === 0) {
console.warn('No roles or groups found for current user');
return;
}
// Use first role and first group (or combine as needed)
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
if (!primaryRole || !primaryGroup) {
return;
}
try {
const response = await $fetch<PermissionResponse>('/api/permission', {
query: {
roles: primaryRole,
groups: primaryGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
apiPermissions.value = response.data;
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
}
} catch (error) {
console.error('Error fetching permissions from API:', error);
// Fallback to local storage if API fails
apiPermissions.value = [];
}
};
const filteredNavItems = computed(() => {
// If no API permissions, check local storage as fallback
if (apiPermissions.value.length === 0) {
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
const roleCandidates = [
...(user.value?.roles || []),
...(user.value?.realm_access?.roles || []),
].map((role) => role.toLowerCase());
const localPermission = hakAksesData.value.find((item) =>
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
);
if (localPermission) {
const permissionMap = new Map(
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
);
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
const menuPerm = permissionMap.get(item.name.toLowerCase());
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = menuPerm ? menuPerm.canAccess : false;
const hasChildren = filteredChildren.length > 0;
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
}
// If no permissions found, show all items
return navItemsStore.navItems;
}
// Use API permissions to filter
const permissionMap = new Map(
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
);
// Mapping untuk pagename dari API ke nama menu di sidebar
const pagenameToMenuMapping: Record<string, string[]> = {
'halaman utama': ['dashboard', 'halaman utama'],
'pengaturan': ['master data'],
'halaman': ['master data'],
'dashboard': ['dashboard'],
};
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
// Try to match by pagename or menu name
let perm = permissionMap.get(item.name.toLowerCase());
// If no direct match, try fuzzy matching
if (!perm) {
perm = Array.from(permissionMap.values()).find(p => {
const pagenameLower = p.pagename?.toLowerCase() || '';
const menuNameLower = item.name.toLowerCase();
// Direct match
if (pagenameLower === menuNameLower) return true;
// Contains match
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
return true;
}
// Check mapping
const mappedMenus = pagenameToMenuMapping[pagenameLower];
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
return true;
}
return false;
});
}
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = perm ? (perm.active || perm.read) : false;
const hasChildren = filteredChildren.length > 0;
// If permission allows and has children, show item with filtered children
// If permission allows but no children, show item
// If no permission but has allowed children, show item with children
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
});
onMounted(async () => {
await checkAuth();
await fetchPermissionsFromAPI();
});
</script>
<style scoped>
.v-list-item--active {
background-color: #e8f5e9 !important;
color: #2e7d32 !important;
}
.v-list-item--active :deep(.v-list-item__prepend) {
color: #2e7d32 !important;
}
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: #f5f5f5;
}
.v-navigation-drawer {
border-right: 1px solid #e0e0e0 !important;
}
/* Global styles for layout */
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<v-app>
<v-main>
<slot />
</v-main>
</v-app>
</template>
<style>
/* Pastikan v-main mengisi seluruh tinggi halaman untuk mengaktifkan centering */
.v-main {
min-height: 100vh;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
import { useAuth } from '~/composables/useAuth';
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
console.log('🛡️ Auth middleware triggered for:', to.path);
// Check for bypass flag (from keyboard shortcut)
if (process.client) {
const bypassFlag = sessionStorage.getItem('bypassRootRedirect');
if (bypassFlag === 'true' && to.path === '/') {
console.log('🔑 Bypass flag detected - allowing root access');
// Clear the flag immediately to prevent future bypasses
sessionStorage.removeItem('bypassRootRedirect');
return; // Allow access without any redirect
}
}
// Redirect root path to LoginPage
if (to.path === '/') {
console.log('🔄 Redirecting from root to LoginPage');
return navigateTo('/LoginPage', { replace: true });
}
// Allow the login page to handle its own checks without redirection loops
if (to.path === '/LoginPage') {
console.log('⏭️ Allowing access to LoginPage');
return;
}
// Skip auth check if it's the development server side render pass.
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping intensive check on server-side during development');
useAuth();
return;
}
// Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true';
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication status using useAuth...');
const user = await checkAuth();
if (user) {
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email);
return;
} else {
console.log('❌ No valid session found, redirecting to login');
return navigateTo('/LoginPage');
}
} catch (error) {
console.error('❌ Auth middleware error:', error);
console.log('🔄 Redirecting to login due to error');
return navigateTo('/LoginPage');
}
});
+44
View File
@@ -0,0 +1,44 @@
// middleware/guest.ts
export default defineNuxtRouteMiddleware(async (to) => {
console.log('👋 Guest middleware triggered for:', to.path);
// Skip middleware on server-side during build/generation
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping guest check on server-side during development');
return;
}
const isAuthRedirect = to.query.authenticated === 'true';
const isLogoutSuccess = to.query.logout === 'success';
// If this is a logout success, allow access to login page
if (isLogoutSuccess) {
console.log('✅ Logout success detected, allowing access to login page');
return;
}
// If this is a redirect from a successful login, we need to wait
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, waiting for session to be established...');
// We navigate to a clean URL to remove the query parameter from the user's view
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking if user is already authenticated...');
// The $fetch will automatically send the new user_session cookie
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
if (session && session.user) {
console.log('✅ User already authenticated, redirecting to dashboard');
return navigateTo('/dashboard');
} else {
console.log('️ No session found, staying on login page');
return;
}
} catch (error) {
console.log('️ Auth check failed (expected for login), staying on login page');
return;
}
});
+239
View File
@@ -0,0 +1,239 @@
// middleware/permissions.ts
// Auto-save user permissions to localStorage when user is authenticated
import { defineNuxtRouteMiddleware } from '#app';
import { useLocalStorage } from '@vueuse/core';
import { useNavItemsStore } from '~/stores/navItems1';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
interface BackendPermission {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface PermissionResponse {
message?: string;
data?: BackendPermission[];
meta?: {
count: number;
total: number;
};
error?: string;
}
// Save permissions to allHakAksesData in localStorage
const savePermissionsToHakAksesData = async (
backendPermissions: BackendPermission[],
role: string,
group: string
) => {
try {
// Get user data for additional info
const userData = await $fetch('/api/users/current').catch(() => null);
// Get existing hak akses data from localStorage
const allHakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
// Check if entry already exists for this role+group combination
const existingIndex = allHakAksesData.value.findIndex(
(item) => item.role === role && item.group === group
);
// Get navItemsStore to build menu template
const navItemsStore = useNavItemsStore();
// Build menu template from navItems
const buildMenuTemplate = (items: NavItem[]): any[] => {
const result: any[] = [];
const walk = (list: NavItem[]) => {
list.forEach((item) => {
result.push({
name: item.name,
canAccess: false,
canView: false,
canAdd: false,
canEdit: false,
canDelete: false,
});
if (item.children?.length) {
walk(item.children);
}
});
};
walk(items);
return result;
};
const menuTemplate = buildMenuTemplate(navItemsStore.navItems);
// Map backend permissions to menu items
const mappedPermissions = menuTemplate.map((menu) => {
// Find matching permission from backend (by pagename or menu name)
const backendPerm = backendPermissions.find((perm) =>
perm.pagename?.toLowerCase() === menu.name.toLowerCase() ||
perm.pagename?.toLowerCase().includes(menu.name.toLowerCase()) ||
menu.name.toLowerCase().includes(perm.pagename?.toLowerCase() || '')
);
if (backendPerm) {
return {
name: menu.name,
canAccess: backendPerm.active || backendPerm.read || false,
canView: backendPerm.read || false,
canAdd: backendPerm.create || false,
canEdit: backendPerm.update || false,
canDelete: backendPerm.delete || false,
};
}
return menu;
});
// Create hak akses data entry
const hakAksesEntry = {
id: existingIndex > -1 ? allHakAksesData.value[existingIndex].id :
(allHakAksesData.value.length > 0
? Math.max(...allHakAksesData.value.map(i => i.id || 0)) + 1
: 1),
userId: userData?.id || '',
namaLengkap: userData?.namaLengkap || '',
namaUser: userData?.namaUser || '',
tipeUser: userData?.tipeUser || '',
role: role,
group: group,
namaTipeUser: userData?.tipeUser || role,
hakAksesMenu: mappedPermissions,
// Store backend permissions for reference
backendPermissions: backendPermissions,
};
if (existingIndex > -1) {
// Update existing entry
allHakAksesData.value[existingIndex] = hakAksesEntry;
console.log('✅ [Permissions Middleware] Updated existing hak akses data for', role, '/', group);
} else {
// Add new entry
allHakAksesData.value.push(hakAksesEntry);
console.log('✅ [Permissions Middleware] Added new hak akses data for', role, '/', group);
}
console.log('💾 [Permissions Middleware] Permissions saved to allHakAksesData:', {
role,
group,
permissionsCount: backendPermissions.length,
menuCount: mappedPermissions.length,
});
} catch (error) {
console.error('❌ [Permissions Middleware] Error saving permissions to hak akses data:', error);
}
};
// Fetch permissions from backend API and save to localStorage
const fetchAndSavePermissions = async () => {
// Skip on server-side
if (process.server) {
return;
}
// Check if we've already processed permissions in this session
const sessionKey = 'permissions_synced';
if (sessionStorage.getItem(sessionKey)) {
console.log('⏭️ [Permissions Middleware] Permissions already synced in this session');
return;
}
try {
// Get current user data with roles and groups
const userData = await $fetch('/api/users/current').catch(() => null);
if (!userData) {
console.warn('⚠️ [Permissions Middleware] No user data found');
return;
}
const roles = [
...(userData.realmRoles || []),
...(userData.roles || []),
];
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const groups: string[] = [];
(userData.groups || []).forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]); // Get second part as group name
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
if (roles.length === 0 || groups.length === 0) {
console.warn('⚠️ [Permissions Middleware] No roles or groups found for current user');
return;
}
// Use first role and first group
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
if (!primaryRole || !primaryGroup) {
return;
}
console.log('🔄 [Permissions Middleware] Fetching permissions for', primaryRole, '/', primaryGroup);
// Fetch permissions from API
const response = await $fetch<PermissionResponse>('/api/permission', {
query: {
roles: primaryRole,
groups: primaryGroup,
},
});
if (response && response.data && Array.isArray(response.data) && response.data.length > 0) {
// Save permissions to localStorage
await savePermissionsToHakAksesData(response.data, primaryRole, primaryGroup);
// Mark as synced in this session
sessionStorage.setItem(sessionKey, 'true');
console.log('✅ [Permissions Middleware] Permissions synced successfully');
} else {
console.warn('⚠️ [Permissions Middleware] No permissions data received from API');
}
} catch (error) {
console.error('❌ [Permissions Middleware] Error fetching/saving permissions:', error);
}
};
export default defineNuxtRouteMiddleware(async (to) => {
// Only run on client-side
if (process.server) {
return;
}
// Skip for login page
if (to.path === '/LoginPage') {
return;
}
// Run async permission sync (non-blocking)
// This will only run once per session due to sessionStorage check
fetchAndSavePermissions().catch(err => {
console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
});
});
+111 -29
View File
@@ -1,37 +1,119 @@
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
compatibilityDate: "2025-05-15",
devtools: {
enabled: true,
timeline: {
enabled: true,
},
},
app: {
head: {
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
]
}
},
modules: [
'@nuxt/content',
'@nuxt/eslint',
'@nuxt/fonts',
'@nuxt/icon',
'@nuxt/image',
'@nuxt/scripts',
'@nuxt/test-utils',
'@nuxt/ui',
(_options, nuxt) => {
nuxt.hooks.hook('vite:extendConfig', (config) => {
// "@nuxt/content",
"@nuxt/eslint",
"@nuxt/fonts",
"@nuxt/icon",
"@nuxt/image",
"@nuxt/scripts",
"@nuxt/test-utils",
"@nuxt/ui",
"@pinia/nuxt",
"@vesp/nuxt-fontawesome",
"@nuxtjs/google-fonts",
async (_options, nuxt) => {
nuxt.hooks.hook("vite:extendConfig", async (config) => {
// @ts-expect-error
config.plugins.push(vuetify({ autoImport: true }))
})
config.plugins.push(vuetify({ autoImport: true }));
// Add HTTPS plugin
try {
// @ts-ignore
const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl');
// @ts-expect-error
config.plugins.push(basicSsl());
// @ts-expect-error
config.server = config.server || {};
// @ts-expect-error
config.server.https = true;
// @ts-expect-error
config.server.host = '10.10.150.175';
// @ts-expect-error
config.server.port = 3001;
} catch (e) {
console.warn('Failed to load HTTPS plugin:', e);
}
});
},
],
build: {
transpile: ['vuetify'] // Important for Nuxt 3 with Vuetify
fontawesome: {
icons: {
solid: ["dna", "user", "home", "gear"],
regular: ["heart"],
brands: ["github"],
},
},
css: [
'vuetify/lib/styles/main.sass', // Or 'vuetify/styles' depending on version
'@mdi/font/css/materialdesignicons.min.css',
],
vite: {
ssr: {
noExternal: ['vuetify']
googleFonts: {
families: {
Inter: [400, 500, 600, 700],
},
plugins: [
vuetify({ autoImport: true }) // If using vite-plugin-vuetify
]
}
})
display: "swap",
},
runtimeConfig: {
authSecret: process.env.NUXT_AUTH_SECRET,
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN,
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
},
},
build: {
transpile: ["vuetify"],
},
css: [
"vuetify/lib/styles/main.sass",
"@mdi/font/css/materialdesignicons.min.css",
"~/assets/scss/main.scss",
],
devServer: {
port: 3001,
host: '10.10.150.175'
},
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "sass:math";
@use "sass:map";
@use "~/assets/scss/_variables.scss" as *;
@use "~/assets/scss/_colors.scss" as *;
`,
},
},
},
ssr: {
noExternal: ["vuetify"],
},
},
});
+4855 -5594
View File
File diff suppressed because it is too large Load diff
+24 -2
View File
@@ -4,28 +4,50 @@
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"_command_dev": "nuxt dev -o --host --port 3001",
"_command_dev2": "nuxt dev -o --port 3001",
"_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001",
"dev": "nuxt dev -o",
"dev:https": "nuxt dev -o --host localhost --port 3001",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@mdi/font": "^7.4.47",
"@nuxt/content": "^3.6.3",
"@nuxt/content": "^2.7.2",
"@nuxt/eslint": "^1.7.1",
"@nuxt/image": "^1.11.0",
"@nuxt/scripts": "^0.11.10",
"@nuxt/test-utils": "^3.19.2",
"@nuxt/ui": "^3.3.0",
"@pinia/nuxt": "^0.11.2",
"@unhead/vue": "^2.0.13",
"@vesp/nuxt-fontawesome": "^2.0.0",
"better-sqlite3": "^12.2.0",
"chart.js": "^4.5.0",
"dayjs": "^1.11.18",
"eslint": "^9.32.0",
"html5-qrcode": "^2.3.8",
"nuxt": "^3.17.7",
"nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"typescript": "^5.8.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
"@types/node": "^24.7.2",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"sass": "^1.93.3",
"sass-embedded": "^1.89.2",
"vite-plugin-vuetify": "^2.1.2",
"vuetify": "^3.9.3"
+419
View File
@@ -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>
+393
View File
@@ -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>
+699
View File
@@ -0,0 +1,699 @@
<!-- pages/AdminPenunjang.vue -->
<template>
<div class="loket-container">
<!-- Compact Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<v-icon size="28" color="white">mdi-clipboard-pulse</v-icon>
<div class="header-text">
<h1 class="page-title">Admin Penunjang</h1>
<p class="page-subtitle">{{ currentDate }}</p>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient -->
<v-col cols="12" md="5">
<div class="sticky-wrapper">
<v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">SEDANG DIPROSES</div>
<div v-if="currentProcessingPatient" class="patient-details">
<div class="patient-number mb-2">{{ currentProcessingPatient.noAntrian.split(" |")[0] }}</div>
<div class="patient-info-text mb-3">
<div>{{ currentProcessingPatient.barcode }}</div>
<div>{{ currentProcessingPatient.klinik }} | {{ currentProcessingPatient.pembayaran }}</div>
</div>
<div class="action-grid">
<v-btn color="#10b981" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'check-in')">
<v-icon start size="20">mdi-check</v-icon>
Selesai
</v-btn>
<v-btn color="#f59e0b" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'terlambat')">
<v-icon start size="20">mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn color="#ef4444" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'pending')">
<v-icon start size="20">mdi-pause</v-icon>
Pending
</v-btn>
<v-btn color="#3b82f6" variant="flat" block size="large" @click="showChangeKlinikDialog = true">
<v-icon start size="20">mdi-swap-horizontal</v-icon>
Ubah Ruang
</v-btn>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="48" color="grey-lighten-2">mdi-account-off-outline</v-icon>
<div class="empty-text">Tidak ada pasien yang diproses</div>
</div>
</v-card-text>
</v-card>
<!-- Queue Actions -->
<v-card class="queue-actions-card mt-3" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">PANGGIL ANTREAN</div>
<div class="quota-info mb-3">
<div class="quota-item">
<span class="quota-label">Kuota</span>
<span class="quota-value">150</span>
</div>
<div class="quota-item">
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ 150 - quotaUsed }}</span>
</div>
<div class="quota-item full-width">
<v-progress-linear :model-value="(quotaUsed / 150) * 100" color="#10b981" height="6" rounded class="mt-1"></v-progress-linear>
<span class="quota-used">Terpakai: {{ quotaUsed }}</span>
</div>
</div>
<div class="call-buttons">
<v-btn color="#10b981" variant="outlined" @click="callNext" :disabled="!nextPatient">1</v-btn>
<v-btn color="#3b82f6" variant="outlined" @click="callMultiplePatients(5)">5</v-btn>
<v-btn color="#f59e0b" variant="outlined" @click="callMultiplePatients(10)">10</v-btn>
<v-btn color="#ef4444" variant="outlined" @click="callMultiplePatients(20)">20</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Create Queue Button -->
<div class="create-buttons mt-3">
<v-btn color="#3b82f6" variant="flat" block size="large" @click="showPenunjangDialog = true">
<v-icon start size="20">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
</div>
</div>
</v-col>
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<v-card class="patient-table-card" elevation="0">
<v-card-text class="pa-4">
<!-- Table Header with Filters -->
<div class="table-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
Semua ({{ allPatients.length }})
</v-chip>
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
Di Penunjang ({{ (diLoketPatients || []).length }})
</v-chip>
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
Terlambat ({{ (terlambatPatients || []).length }})
</v-chip>
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
Pending ({{ (pendingPatients || []).length }})
</v-chip>
</v-chip-group>
<v-text-field
v-model="searchQuery"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
></v-text-field>
</div>
</div>
<!-- Data Table -->
<v-data-table
:headers="tableHeaders"
:items="filteredPatients"
:search="searchQuery"
class="patient-table"
:items-per-page="10"
density="comfortable"
>
<template #item.noAntrian="{ item }">
<div class="queue-number">{{ item.noAntrian.split(" |")[0] }}</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(item.status) }}
</v-chip>
</template>
<template #item.klinik="{ item }">
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ item.klinik }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<div class="action-buttons-table">
<v-btn
v-if="item.status === 'diloket'"
size="small"
color="#10b981"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
<v-btn
v-else-if="item.status === 'terlambat'"
size="small"
color="#10b981"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
<v-btn
v-else-if="item.status === 'pending'"
size="small"
color="#10b981"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Penunjang Dialog -->
<v-dialog v-model="showPenunjangDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showPenunjangDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Pilih Ruang Penunjang</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="penunjangSearch"
placeholder="Cari penunjang..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="penunjang in filteredPenunjangs" :key="penunjang.id" cols="6" sm="4">
<v-card class="penunjang-card" @click="selectPenunjang(penunjang)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="penunjang-name">{{ penunjang.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- Change Ruang Dialog -->
<v-dialog v-model="showChangeKlinikDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showChangeKlinikDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Ubah Ruang Penunjang</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="changeKlinikSearch"
placeholder="Cari ruang..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="klinik in filteredChangeKliniks" :key="klinik.id" cols="6" sm="4">
<v-card class="klinik-card" @click="changeKlinik(klinik)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="klinik-name">{{ klinik.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
{{ snackbarText }}
<template v-slot:actions>
<v-btn icon size="small" @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "../composables/useQueue";
const {
snackbar,
snackbarText,
snackbarColor,
showPenunjangDialog,
showChangeKlinikDialog,
penunjangSearch,
changeKlinikSearch,
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
nextPatient,
quotaUsed,
filteredPenunjangs,
filteredChangeKliniks,
callNext,
callMultiplePatients,
processPatient,
selectPenunjang,
changeKlinik,
} = useQueue("penunjang");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
const selectedStatus = ref("all");
const searchQuery = ref("");
// Combine all patients with status - Fixed to prevent infinite loop
const allPatients = computed(() => {
try {
// Safely access reactive values
const diLoketList = diLoketPatients.value || [];
const terlambatList = terlambatPatients.value || [];
const pendingList = pendingPatients.value || [];
// Map with status
const diLoket = diLoketList.map(p => ({ ...p, status: 'diloket' }));
const terlambat = terlambatList.map(p => ({ ...p, status: 'terlambat' }));
const pending = pendingList.map(p => ({ ...p, status: 'pending' }));
return [...diLoket, ...terlambat, ...pending];
} catch (error) {
console.error('Error in allPatients computed:', error);
return [];
}
});
// Filter patients based on selected status
const filteredPatients = computed(() => {
try {
const all = allPatients.value;
if (selectedStatus.value === "all") return all;
return all.filter(p => p.status === selectedStatus.value);
} catch (error) {
console.error('Error in filteredPatients computed:', error);
return [];
}
});
const tableHeaders = ref([
{ title: "No", value: "no", width: "60px", sortable: false },
{ title: "Jam Panggil", value: "jamPanggil", width: "100px" },
{ title: "Barcode", value: "barcode", width: "130px" },
{ title: "No Antrian", value: "noAntrian", width: "140px" },
{ title: "Klinik", value: "klinik", width: "100px" },
{ title: "Fast Track", value: "fastTrack", width: "100px" },
{ title: "Pembayaran", value: "pembayaran", width: "100px" },
{ title: "Status", value: "status", width: "100px" },
{ title: "Aksi", value: "aksi", width: "100px", sortable: false },
]);
const getStatusColor = (status) => {
const colors = {
diloket: "#3b82f6",
terlambat: "#f59e0b",
pending: "#ef4444"
};
return colors[status] || "#6b7280";
};
const getStatusLabel = (status) => {
const labels = {
diloket: "Di Penunjang",
terlambat: "Terlambat",
pending: "Pending"
};
return labels[status] || status;
};
</script>
<style scoped>
.loket-container {
background: #f8fafc;
min-height: 100vh;
padding: 16px;
}
.page-header {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
.header-content {
padding: 20px 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-text {
color: white;
}
.page-title {
font-size: 24px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.page-subtitle {
font-size: 14px;
margin: 4px 0 0 0;
opacity: 0.9;
}
.content-grid {
margin: 0 -8px;
}
.content-grid > .v-col {
padding: 8px;
}
.sticky-wrapper {
position: sticky;
top: 16px;
align-self: flex-start;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.current-patient-card,
.queue-actions-card,
.patient-table-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
background: white;
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: #64748b;
text-transform: uppercase;
}
.patient-details {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
border-radius: 8px;
padding: 16px;
}
.patient-number {
font-size: 28px;
font-weight: 800;
color: #1e293b;
line-height: 1;
}
.patient-info-text {
font-size: 13px;
color: #475569;
line-height: 1.5;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.empty-state {
text-align: center;
padding: 32px 16px;
}
.empty-text {
font-size: 13px;
color: #94a3b8;
margin-top: 8px;
}
.quota-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.quota-item {
background: #f1f5f9;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.quota-item.full-width {
grid-column: 1 / -1;
}
.quota-label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
font-weight: 600;
}
.quota-value {
display: block;
font-size: 24px;
font-weight: 800;
color: #1e293b;
}
.quota-available {
color: #10b981;
}
.quota-used {
display: block;
font-size: 11px;
color: #64748b;
margin-top: 4px;
}
.call-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.call-buttons .v-btn {
font-size: 16px;
font-weight: 700;
height: 44px;
}
.create-buttons .v-btn {
text-transform: none;
font-weight: 600;
}
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid #e2e8f0;
background: white;
color: #64748b;
}
.status-filter .v-chip.active-chip {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.search-field {
max-width: 250px;
}
:deep(.patient-table) {
border-radius: 8px;
}
:deep(.patient-table .v-data-table__thead) {
background: #f8fafc;
}
:deep(.patient-table th) {
font-size: 11px !important;
font-weight: 700 !important;
color: #64748b !important;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 8px !important;
}
:deep(.patient-table td) {
font-size: 13px !important;
padding: 10px 8px !important;
border-bottom: 1px solid #f1f5f9 !important;
}
:deep(.patient-table tbody tr:hover) {
background: #f8fafc !important;
}
.queue-number {
font-weight: 700;
color: #1e293b;
}
.status-chip {
font-weight: 600;
font-size: 11px;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
}
.action-buttons-table .v-btn {
text-transform: none;
font-weight: 600;
font-size: 12px;
height: 32px;
}
.dialog-header {
background: #f8fafc;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
}
.klinik-card,
.penunjang-card {
cursor: pointer;
transition: all 0.2s;
border: 2px solid #e2e8f0;
background: white;
}
.klinik-card:hover,
.penunjang-card:hover {
border-color: #8b5cf6;
background: #f5f3ff;
}
.klinik-name,
.penunjang-name {
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.v-btn {
text-transform: none !important;
letter-spacing: 0 !important;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
.sticky-wrapper {
position: relative;
top: 0;
max-height: none;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.search-field {
max-width: 100%;
}
.action-grid {
grid-template-columns: 1fr;
}
.call-buttons {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
File diff suppressed because it is too large Load diff
-686
View File
@@ -1,686 +0,0 @@
<!-- pages/Anjungan.vue -->
<template>
<div class="anjungan-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Anjungan RSSA</h1>
<p class="page-subtitle">Pilih Klinik untuk Pendaftaran</p>
</div>
</div>
<div class="header-right">
<v-chip color="white" variant="flat" class="instruction-chip">
<v-icon start size="16">mdi-information</v-icon>
Hijau: Tersedia | Merah: Tutup/Penuh
</v-chip>
</div>
</div>
</div>
<!-- Controls Section -->
<!-- <v-card class="controls-card mb-4" elevation="2">
<v-card-text class="py-3">
<v-row align="center">
<v-col cols="12" md="6">
<div class="d-flex align-center flex-wrap gap-3">
<v-select
v-model="selectedStatus"
:items="statusOptions"
label="Filter Status"
density="compact"
variant="outlined"
hide-details
clearable
style="min-width: 160px;"
/>
<v-text-field
v-model="searchQuery"
label="Cari Klinik"
density="compact"
variant="outlined"
prepend-inner-icon="mdi-magnify"
hide-details
clearable
style="min-width: 200px;"
/>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="d-flex justify-end align-center flex-wrap gap-2">
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
@click="refreshData"
:loading="loading"
size="small"
>
Refresh
</v-btn>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card> -->
<!-- Clinic Cards -->
<v-card elevation="2" class="main-content-card">
<!-- <v-card-title class="d-flex align-center pa-4 bg-grey-lighten-4">
<v-icon class="mr-2">mdi-hospital-marker</v-icon>
<span>Daftar Klinik - {{ filteredClinics.length }} dari {{ totalClinics }} klinik</span>
</v-card-title> -->
<v-card-text class="pa-6">
<v-row>
<v-col
v-for="clinic in filteredClinics"
:key="clinic.id"
cols="12"
sm="6"
md="4"
lg="3"
class="pa-2"
>
<v-card
:class="{
'clinic-card': true,
'clinic-available': clinic.available,
'clinic-closed': !clinic.available
}"
:disabled="!clinic.available"
@click="selectClinic(clinic)"
elevation="2"
>
<v-card-text class="text-center pa-4">
<!-- Status Chip -->
<div class="mb-3">
<v-chip
:color="clinic.available ? 'success' : 'error'"
size="small"
variant="flat"
>
{{ clinic.available ? 'TERSEDIA' : 'TUTUP' }}
</v-chip>
</div>
<!-- Icon -->
<div class="clinic-icon-wrapper mb-3">
<v-icon
:icon="clinic.icon"
size="40"
:color="clinic.available ? 'success' : 'error'"
></v-icon>
</div>
<!-- Clinic Name -->
<h3 class="text-h6 font-weight-bold mb-2">
{{ clinic.name }}
</h3>
<!-- Subtitle -->
<p v-if="clinic.subtitle" class="text-caption text-grey-darken-1 mb-2">
{{ clinic.subtitle }}
</p>
<!-- Shift Info -->
<div class="shift-info">
<v-chip
size="small"
:color="clinic.available ? 'info' : 'error'"
variant="outlined"
class="mb-2"
>
{{ clinic.shift }}
</v-chip>
<br>
<span v-if="clinic.schedule" class="text-caption text-grey-darken-1">
{{ clinic.schedule }}
</span>
</div>
<!-- Action Button -->
<div class="mt-3">
<v-btn
v-if="clinic.available"
color="success"
variant="flat"
size="small"
block
>
Pilih Klinik
</v-btn>
<v-btn
v-else
color="error"
variant="outlined"
size="small"
disabled
block
>
Tidak Tersedia
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Empty State -->
<div v-if="filteredClinics.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-hospital-marker-outline</v-icon>
<h3 class="text-h6 mt-4 text-grey-darken-1">Tidak ada klinik yang sesuai filter</h3>
<p class="text-body-2 text-grey-darken-1">Coba ubah filter pencarian Anda</p>
</div>
</v-card-text>
</v-card>
<!-- Selection Dialog -->
<v-dialog v-model="showDialog" max-width="500">
<v-card>
<v-card-title class="d-flex align-center bg-primary text-white">
<v-icon class="mr-2">mdi-check-circle</v-icon>
Konfirmasi Pilihan
</v-card-title>
<v-card-text class="pa-6" v-if="selectedClinic">
<div class="text-center">
<div class="mb-3">
<v-icon :icon="selectedClinic.icon" size="48" color="primary"></v-icon>
</div>
<h3 class="text-h5 font-weight-bold mb-2">{{ selectedClinic.name }}</h3>
<p v-if="selectedClinic.subtitle" class="text-body-1 text-grey-darken-1 mb-3">
{{ selectedClinic.subtitle }}
</p>
<v-divider class="my-4"></v-divider>
<div class="text-left">
<p><strong>Shift:</strong> {{ selectedClinic.shift }}</p>
<p v-if="selectedClinic.schedule"><strong>Jadwal:</strong> {{ selectedClinic.schedule }}</p>
<p><strong>Status:</strong> <span class="text-success">Tersedia</span></p>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn @click="showDialog = false" variant="text">
Batal
</v-btn>
<v-btn
color="primary"
@click="proceedToRegistration"
variant="flat"
>
Lanjutkan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
location="top right"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn icon @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
definePageMeta({
layout: false, // Disables the layout for this specific page
});
// Reactive data
const loading = ref(false)
const selectedStatus = ref(null)
const searchQuery = ref('')
const showDialog = ref(false)
const selectedClinic = ref(null)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
// Options
const statusOptions = ['Tersedia', 'Tutup']
// Clinic data
const clinics = ref([
{
id: 1,
name: 'ANAK',
subtitle: '',
icon: 'mdi-baby-face',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 2,
name: 'ANESTESI',
subtitle: '',
icon: 'mdi-sleep',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 3,
name: 'BEDAH',
subtitle: '',
icon: 'mdi-medical-bag',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 4,
name: 'GERIATRI',
subtitle: '',
icon: 'mdi-account-supervisor',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 5,
name: 'GIGI DAN MULUT',
subtitle: 'GIGI DAN MULUT',
icon: 'mdi-tooth',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 6,
name: 'GIZI',
subtitle: '',
icon: 'mdi-food-apple',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 7,
name: 'HOM',
subtitle: 'HEMATO ONKOLOGI MEDIS',
icon: 'mdi-water',
shift: 'TUTUP',
schedule: '',
available: false,
},
{
id: 8,
name: 'IPD',
subtitle: 'PENYAKIT DALAM',
icon: 'mdi-hospital',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 9,
name: 'JANTUNG',
subtitle: 'CARDIOLOGI',
icon: 'mdi-heart-pulse',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 10,
name: 'JIWA',
subtitle: '',
icon: 'mdi-brain',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 11,
name: 'KANDUNGAN',
subtitle: '',
icon: 'mdi-human-pregnant',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 12,
name: 'KEMOTERAPI',
subtitle: '',
icon: 'mdi-needle',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 13,
name: 'KOMPLEMENTER',
subtitle: 'NYERI',
icon: 'mdi-leaf',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 14,
name: 'KUL KEL',
subtitle: 'KULIT KELAMIN',
icon: 'mdi-hand-back-right',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 15,
name: 'MATA',
subtitle: '',
icon: 'mdi-eye',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 16,
name: 'MCU',
subtitle: '',
icon: 'mdi-clipboard-check',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 17,
name: 'ONKOLOGI',
subtitle: '',
icon: 'mdi-ribbon',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 18,
name: 'PARU',
subtitle: '',
icon: 'mdi-lungs',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 19,
name: 'R. TINDAKAN',
subtitle: 'EMG, ECG, DLL',
icon: 'mdi-monitor-heart-rate',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 20,
name: 'RADIOTERAPI',
subtitle: '',
icon: 'mdi-radioactive',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 21,
name: 'REHAB MEDIK',
subtitle: '',
icon: 'mdi-human-cane',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
},
{
id: 22,
name: 'SARAF',
subtitle: 'NEUROLOGI',
icon: 'mdi-head-cog',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: false,
},
{
id: 23,
name: 'THT',
subtitle: '',
icon: 'mdi-ear-hearing',
shift: 'SHIFT 1',
schedule: 'Mulai Pukul 07:00',
available: true,
}
])
// Computed properties
const filteredClinics = computed(() => {
let filtered = clinics.value
if (selectedStatus.value) {
const isAvailable = selectedStatus.value === 'Tersedia'
filtered = filtered.filter(clinic => clinic.available === isAvailable)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(clinic =>
clinic.name.toLowerCase().includes(query) ||
clinic.subtitle.toLowerCase().includes(query)
)
}
return filtered
})
const totalClinics = computed(() => clinics.value.length)
// Methods
const showSnackbar = (text, color = 'success') => {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
const selectClinic = (clinic) => {
if (clinic.available) {
selectedClinic.value = clinic
showDialog.value = true
}
}
const proceedToRegistration = () => {
showSnackbar(`Mengarahkan ke pendaftaran ${selectedClinic.value.name}...`, 'success')
console.log('Proceeding to registration for:', selectedClinic.value.name)
showDialog.value = false
}
const refreshData = () => {
loading.value = true
setTimeout(() => {
loading.value = false
showSnackbar('Status klinik berhasil diperbarui', 'success')
}, 1000)
}
// Lifecycle
onMounted(() => {
refreshData()
})
</script>
<style scoped>
.anjungan-container {
background: #f5f7fa;
min-height: 100vh;
padding: 20px;
}
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: white;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
}
.instruction-chip {
font-weight: 500;
color: #1976d2 !important;
}
.controls-card,
.main-content-card {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.clinic-card {
cursor: pointer;
border-radius: 16px !important;
height: 280px;
background: white;
}
.clinic-available {
border-left: 6px solid #4CAF50;
}
.clinic-closed {
opacity: 0.7;
cursor: not-allowed;
border-left: 6px solid #F44336;
background: #fafafa;
}
.clinic-icon-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 70px;
height: 70px;
background: rgba(76, 175, 80, 0.1);
border-radius: 50%;
margin: 0 auto;
}
.clinic-closed .clinic-icon-wrapper {
background: rgba(244, 67, 54, 0.1);
}
.shift-info {
margin-top: 8px;
}
/* Responsive Design */
@media (max-width: 1024px) {
.header-content {
flex-direction: column;
gap: 20px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.page-title {
font-size: 28px;
}
}
@media (max-width: 768px) {
.anjungan-container {
padding: 16px;
}
.header-content {
padding: 24px 20px;
}
.page-title {
font-size: 24px;
}
.header-icon {
padding: 12px;
}
.clinic-card {
height: 260px;
}
.clinic-icon-wrapper {
width: 60px;
height: 60px;
}
}
@media (max-width: 600px) {
.clinic-card {
height: 240px;
}
.page-title {
font-size: 20px;
}
}
</style>
File diff suppressed because it is too large Load diff
+352
View File
@@ -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>
-543
View File
@@ -1,543 +0,0 @@
<!-- pages/AntrianKlinik.vue -->
<template>
<div class="tv-display-container">
<!-- Header -->
<div class="tv-header">
<div class="tv-header-left">
<div class="hospital-logo">
<v-icon size="64" color="white">mdi-hospital-box</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN KLINIK</h1>
<p class="display-title">RSUD Dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div class="tv-header-right">
<div class="current-time">{{ currentTime }}</div>
<div class="current-date">{{ currentDate }}</div>
</div>
</div>
<!-- Current Called Queue - Super Prominent -->
<div v-if="currentCalledQueue" class="hero-queue">
<div class="hero-label">SEDANG DIPANGGIL</div>
<div class="hero-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="hero-clinic">{{ currentCalledQueue.klinik }}</div>
</div>
<!-- Clinic Grid - 3x3 Layout -->
<div class="clinic-grid">
<div
v-for="clinic in displayedClinics"
:key="clinic.name"
class="clinic-card"
>
<div class="clinic-name">{{ clinic.name }}</div>
<div class="queue-display">
<!-- Show current queue number -->
<div v-if="clinic.currentQueue" class="current-queue-large">
<div class="queue-label">SEKARANG</div>
<div class="queue-number-huge">
{{ clinic.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Show next queues -->
<div v-if="clinic.nextQueues.length > 0" class="next-queues">
<div class="queue-label-small">SELANJUTNYA</div>
<div class="next-queue-numbers">
<span
v-for="(queue, index) in clinic.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-number"
>
{{ queue.noAntrian.split(' |')[0] }}
</span>
</div>
</div>
<!-- Empty state -->
<div v-if="!clinic.currentQueue && clinic.nextQueues.length === 0" class="empty-queue">
<v-icon size="48" color="grey-lighten-2">mdi-checkbox-blank-circle-outline</v-icon>
<div class="empty-text">Tidak Ada Antrian</div>
</div>
</div>
<!-- Queue count badge -->
<div class="queue-count">
<v-icon size="20" class="mr-1">mdi-account-multiple</v-icon>
{{ clinic.totalQueues }}
</div>
</div>
</div>
<!-- Footer Info -->
<div class="tv-footer">
<div class="footer-stats">
<div class="stat-box">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ statistics.waiting }}</div>
<div class="stat-label">Menunggu</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" class="mr-2">mdi-information</v-icon>
Harap perhatikan nomor antrian Anda di layar
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '../stores/queueStore'
definePageMeta({
layout: false,
});
// Store
const queueStore = useQueueStore()
// Reactive data
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
let refreshInterval = null
// Computed - Get all klinik patients (stage: klinik)
const klinikPatients = computed(() => {
return queueStore.getPatientsByStage('klinik').value.all
})
// Get clinics with their queues organized
const displayedClinics = computed(() => {
const allKliniks = queueStore.kliniks.slice(0, 9) // Max 9 clinics for 3x3 grid
return allKliniks.map(klinik => {
const queues = klinikPatients.value
.filter(p => p.klinik === klinik.name)
.sort((a, b) => {
// Sort by status priority then time
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'terlambat': 3,
'pending': 4
}
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
// Sort by time if same status
const timeA = a.jamPanggil.split(':').map(Number)
const timeB = b.jamPanggil.split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
// Get current (first di-loket or first waiting)
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
// Get next queues (excluding current)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
)
return {
name: klinik.name,
currentQueue: currentQueue,
nextQueues: nextQueues,
totalQueues: queues.length
}
})
})
// Current queue being called (most recent di-loket)
const currentCalledQueue = computed(() => {
const calledQueues = klinikPatients.value
.filter(p => p.status === 'di-loket')
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return calledQueues[0] || null
})
// Statistics
const statistics = computed(() => {
const all = klinikPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
// Methods
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// Lifecycle
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
// Auto refresh every 5 seconds
refreshInterval = setInterval(() => {
// Data will auto-update due to reactive store
}, 5000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
if (refreshInterval) clearInterval(refreshInterval)
})
</script>
<style scoped>
.tv-display-container {
background: linear-gradient(135deg, #e7e7e7 0%, #FFDCAF 100%);
min-height: 100vh;
padding: 20px;
font-family: 'Roboto', sans-serif;
color: #fff;
overflow: hidden;
}
/* Header */
.tv-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #FAFAFA;
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 20px 40px;
margin-bottom: 20px;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
}
.tv-header-left {
display: flex;
align-items: center;
gap: 20px;
}
.hospital-logo {
background: #1e3c72;
border-radius: 50%;
padding: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.hospital-name {
font-size: 36px;
margin: 0;
letter-spacing: 2px;
color: #1e3c72;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.display-title {
font-size: 24px;
font-weight: 600;
margin: 0;
color: #1e3c72;
opacity: 0.9;
}
.tv-header-right {
text-align: right;
}
.current-time {
font-size: 48px;
font-weight: 700;
line-height: 1;
color: #1e3c72;
text-shadow: 2px 2px 4px rgba(124, 124, 124, 0.3);
}
.current-date {
font-size: 18px;
opacity: 0.9;
margin-top: 5px;
}
/* Hero Queue - Sedang Dipanggil */
.hero-queue {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
border-radius: 24px;
padding: 30px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
animation: pulse-glow 2s infinite;
}
.hero-label {
font-size: 32px;
font-weight: 700;
letter-spacing: 3px;
margin-bottom: 10px;
text-transform: uppercase;
}
.hero-number {
font-size: 120px;
font-weight: 900;
line-height: 1;
margin: 10px 0;
text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
letter-spacing: 4px;
}
.hero-clinic {
font-size: 40px;
font-weight: 600;
margin-top: 10px;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
}
50% {
box-shadow: 0 16px 64px rgba(255, 107, 107, 0.6);
}
}
/* Clinic Grid - 3x3 */
.clinic-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 20px;
}
.clinic-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
position: relative;
min-height: 200px;
display: flex;
flex-direction: column;
}
.clinic-name {
font-size: 28px;
font-weight: 800;
color: #1e3c72;
text-align: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 3px solid #2a5298;
text-transform: uppercase;
letter-spacing: 1px;
}
.queue-display {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* Current Queue Display */
.current-queue-large {
text-align: center;
margin-bottom: 10px;
}
.queue-label {
font-size: 18px;
font-weight: 600;
color: #4caf50;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
.queue-number-huge {
font-size: 72px;
font-weight: 900;
color: #1e3c72;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
/* Next Queues */
.next-queues {
text-align: center;
}
.queue-label-small {
font-size: 14px;
font-weight: 600;
color: #666;
margin-bottom: 5px;
text-transform: uppercase;
}
.next-queue-numbers {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.next-number {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 12px;
font-size: 24px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
/* Empty State */
.empty-queue {
text-align: center;
opacity: 0.5;
}
.empty-text {
font-size: 18px;
color: #999;
margin-top: 10px;
font-weight: 500;
}
/* Queue Count Badge */
.queue-count {
position: absolute;
top: 15px;
right: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Footer */
.tv-footer {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.footer-stats {
display: flex;
gap: 40px;
}
.stat-box {
text-align: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
line-height: 1;
color:#1e3c72;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.stat-label {
font-size: 16px;
opacity: 0.9;
margin-top: 5px;
color:#1e3c72;
font-weight: 500;
}
.footer-message {
font-size: 22px;
font-weight: 600;
display: flex;
align-items: center;
opacity: 0.95;
color:#1e3c72;
}
/* Responsive for different TV sizes */
@media (max-width: 1920px) {
.hero-number {
font-size: 100px;
}
.queue-number-huge {
font-size: 64px;
}
}
@media (max-width: 1366px) {
.clinic-grid {
gap: 10px;
}
.clinic-card {
padding: 15px;
min-height: 180px;
}
.clinic-name {
font-size: 24px;
}
.queue-number-huge {
font-size: 56px;
}
.hero-number {
font-size: 90px;
}
}
/* Animation for smooth transitions */
.clinic-card {
transition: all 0.3s ease;
}
.queue-number-huge,
.hero-number {
transition: all 0.3s ease;
}
</style>
File diff suppressed because it is too large Load diff
+356
View File
@@ -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>
+381
View File
@@ -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>
+520
View File
@@ -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>
+858
View File
@@ -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>
+744
View File
@@ -0,0 +1,744 @@
<template>
<v-container fluid class="pa-8 dashboard-bg">
<div class="d-flex justify-space-between align-center mb-10">
<div>
<h1 class="dashboard-title">
Antrean Dashboard
</h1>
<p v-if="user" class="dashboard-subtitle">
Selamat Datang,
<span class="user-name">
{{ user.name || user.preferred_username }}
</span>! 🍊
</p>
</div>
<div class="d-flex align-center">
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="primary-600"
variant="flat"
class="export-btn mr-4"
>
<v-icon start icon="mdi-download-box-outline"></v-icon>
Export Data ({{ exportType }})
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, index) in exportOptions"
:key="index"
@click="handleExport(item.type)"
>
<v-list-item-title>
<v-icon start :icon="item.icon"></v-icon>
{{ item.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-chip color="white" variant="flat" class="date-chip">
<v-icon start icon="mdi-calendar-check-outline" color="primary-600"></v-icon>
{{ currentDate }}
</v-chip>
</div>
</div>
<v-row class="mb-10">
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="48" color="primary-600" class="mr-4">mdi-tablet-dashboard</v-icon>
<div>
<div class="stat-value">150</div>
<div class="stat-label">Total Antrean Online</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="48" color="secondary-600" class="mr-4">mdi-account-hard-hat</v-icon>
<div>
<div class="stat-value">100</div>
<div class="stat-label">Total Antrean MJKN</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="48" color="primary-600" class="mr-4">mdi-account-group</v-icon>
<div>
<div class="stat-value">500</div>
<div class="stat-label">Total Kunjungan</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="48" color="secondary-600" class="mr-4">mdi-timer-sand</v-icon>
<div>
<div class="stat-value">7.5s</div>
<div class="stat-label">Avg. Check-in Time</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="primary-600" class="period-toggle">
<v-btn size="small" value="day">Day</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text class="pa-0">
<Bar
:data="queueChartData"
:options="queueChartOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Total Registrasi Perbulan</span>
<div>
<v-chip
:color="activeYear === '2024' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2024' ? 'white' : 'neutral-700'"
variant="flat"
class="year-chip mr-2"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2025' ? 'white' : 'neutral-700'"
variant="flat"
class="year-chip"
@click="changeYear('2025')"
>
2025
</v-chip>
</div>
</v-card-title>
<v-card-text class="pa-0">
<Bar
:data="barData"
:options="barOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Realtime Check-in Velocity</span>
<v-chip size="small" color="success-600" class="live-chip">
<v-icon start size="small">mdi-circle</v-icon>
Live
</v-chip>
</v-card-title>
<v-card-text class="pa-0">
<Line
:data="realtimeLineData"
:options="lineOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Registrant Type Breakdown</span>
</v-card-title>
<v-card-text class="d-flex justify-center align-center pa-0" style="height: 350px;">
<Pie
:data="pieData"
:options="pieOptions"
style="max-height: 100%; max-width: 100%;"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="primary-600"></v-progress-circular>
<p class="loading-text">Loading dashboard...</p>
</v-overlay>
</v-container>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import { useAuth } from '~/composables/useAuth';
import dayjs from 'dayjs';
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import 'dayjs/locale/id';
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
dayjs.locale('id');
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
PointElement,
LineElement,
} from 'chart.js';
definePageMeta({
middleware:['auth']
})
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
const user = ref(null);
const isLoading = ref(false);
const { checkAuth } = useAuth();
const exportType = ref('JSON');
const exportOptions = ref([
{ title: 'JSON (.json)', type: 'json', icon: 'mdi-code-json' },
{ title: 'CSV (.csv)', type: 'csv', icon: 'mdi-file-delimited' },
{ title: 'Excel (.xlsx) - Server Only', type: 'excel', icon: 'mdi-file-excel' },
{ title: 'PDF (.pdf) - Placeholder', type: 'pdf', icon: 'mdi-file-pdf' },
]);
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('day');
const mockQueueData = ref([
{ date: '2025-10-13', registrants: 300, attendees: 250 },
{ date: '2025-10-14', registrants: 450, attendees: 400 },
{ date: '2025-10-15', registrants: 500, attendees: 420 },
{ date: '2025-10-16', registrants: 400, attendees: 350 },
{ date: '2025-10-17', registrants: 550, attendees: 500 },
{ date: '2025-10-18', registrants: 435, attendees: 380 },
]);
// --- SIMPLIFIED REALTIME CHART (NO AUTO-UPDATE) ---
const maxDataPoints = 10;
const realtimeLineData = ref({
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 30, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Check-ins/min',
backgroundColor: 'rgba(6, 99, 199, 0.1)',
borderColor: '#0671E0',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
fill: true,
tension: 0.3,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#0663C7',
},
],
});
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
plugins: {
legend: { display: true },
title: { display: false }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 80,
title: { display: true, text: 'Check-ins per Minute' },
grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
}
});
// --- END SIMPLIFIED REALTIME CHART ---
// Single onMounted
onMounted(async () => {
console.log('📊 Dashboard mounted');
try {
const sessionUser = await checkAuth();
if (sessionUser) {
user.value = sessionUser;
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
console.log('✅ Dashboard loaded successfully');
}
} catch (error) {
console.error('❌ Auth check error:', error);
}
});
// Clean onUnmounted
onUnmounted(() => {
console.log('🧹 Dashboard unmounting');
});
const changeYear = (year) => {
activeYear.value = year;
};
const getKpiData = () => {
return [
{ name: 'Antrean Online', value: 150 },
{ name: 'Antrean MJKN', value: 100 },
{ name: 'Total Kunjungan', value: 500 },
{ name: 'Avg. Check-in Time', value: '7.5s' },
];
};
const convertJsonToCsv = (jsonData, fields) => {
if (!jsonData.length) return '';
const header = fields.join(',');
const csv = jsonData.map(row =>
fields.map(fieldName => {
let value = row[fieldName];
if (typeof value === 'string') {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
).join('\n');
return header + '\n' + csv;
};
const downloadFile = (data, filename, mimeType) => {
const dataStr = `data:${mimeType};charset=utf-8,` + encodeURIComponent(data);
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", filename);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
const handleExport = (type) => {
exportType.value = type.toUpperCase();
const filenamePrefix = "antrean_data_" + dayjs().format('YYYYMMDD');
const dataToExport = mockQueueData.value;
if (type === 'json') {
downloadFile(JSON.stringify(dataToExport, null, 2), `${filenamePrefix}.json`, 'application/json');
alert("Data Antrean berhasil diexport sebagai JSON! ✅");
} else if (type === 'csv') {
const fields = ['date', 'registrants', 'attendees'];
const csvContent = convertJsonToCsv(dataToExport, fields);
const kpiData = getKpiData().map(kpi => `# ${kpi.name}: ${kpi.value}`).join('\n');
const finalCsvContent = kpiData + '\n' + csvContent;
downloadFile(finalCsvContent, `${filenamePrefix}.csv`, 'text/csv');
alert("Data Antrean berhasil diexport sebagai CSV! 📊");
} else if (type === 'excel') {
alert("Excel (.xlsx) export requires a dedicated server endpoint. Simulating download... ⚠️");
} else if (type === 'pdf') {
alert("PDF (.pdf) export requires client-side libraries (like jsPDF) or a server service. Simulating download... 📝");
}
};
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
...item,
date: dayjs(item.date)
})).sort((a, b) => a.date.valueOf() - b.date.valueOf());
sortedDates.forEach(item => {
let key;
let label;
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
label = item.date.format('ddd, DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
label = `Wk ${item.date.week()}`;
} else if (period === 'month') {
key = item.date.format('YYYY-MM');
label = item.date.format('MMM YYYY');
}
if (!grouped[key]) {
grouped[key] = { label: label, registrants: 0, attendees: 0 };
}
grouped[key].registrants += item.registrants;
grouped[key].attendees += item.attendees;
});
const finalLabels = Object.values(grouped).map(g => g.label);
const finalRegistrants = Object.values(grouped).map(g => g.registrants);
const finalAttendees = Object.values(grouped).map(g => g.attendees);
return {
labels: finalLabels,
datasets: [
{
label: `Registrants`,
backgroundColor: '#FFB95F',
data: finalRegistrants,
yAxisID: 'y1',
type: 'bar',
borderRadius: 6,
},
{
label: `Attendees`,
backgroundColor: 'transparent',
borderColor: '#0671E0',
data: finalAttendees,
yAxisID: 'y1',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#0663C7',
tension: 0.4,
borderWidth: 3,
},
],
};
};
const queueChartData = computed(() => {
if (!mockQueueData.value.length) return { labels: [], datasets: [] };
return processQueueData(mockQueueData.value, queueTimePeriod.value);
});
const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' },
},
scales: {
y1: {
beginAtZero: true,
title: { display: true, text: 'Count' },
grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
grid: { display: false }
}
}
});
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
const conversionRate2025 = [2.5, 3.1, 4.0, 4.5, 4.2, 5.5, 6.0, 5.8, 5.7, 5.2, 6.5, 7.5];
const barData = computed(() => {
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
const conversionData = activeYear.value === '2024' ? [2.0, 2.5, 3.5, 3.8, 3.6, 4.5, 5.0, 5.2, 4.8, 4.5, 5.5, 6.0] : conversionRate2025;
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
label: `Total Registrants`,
backgroundColor: '#FFB95F',
data: dataForYear,
yAxisID: 'y1',
type: 'bar',
borderRadius: 6,
},
{
label: `Conv. Rate (%)`,
borderColor: '#0671E0',
backgroundColor: 'transparent',
data: conversionData,
yAxisID: 'y2',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#0663C7',
tension: 0.4,
borderWidth: 3,
}
],
};
});
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' },
},
scales: {
y1: {
position: 'left',
beginAtZero: true,
title: { display: true, text: 'Registrants' },
grid: { display: false }
},
y2: {
position: 'right',
beginAtZero: true,
suggestedMax: 10,
title: { display: true, text: 'Conv. Rate (%)' },
grid: { drawOnChartArea: false }
},
x: {
grid: { display: false }
}
}
});
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#0671E0', '#FFB95F'],
hoverBackgroundColor: ['#0053AD', '#FF9B1B'],
data: [759, 1876],
borderWidth: 3,
borderColor: '#ffffff',
},
],
});
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
});
</script>
<style scoped lang="scss">
/* Background */
.dashboard-bg {
background: var(--color-neutral-300);
}
/* Typography */
.dashboard-title {
font-size: 32px;
font-weight: 800;
color: var(--color-neutral-900);
margin: 0;
line-height: 1.2;
}
.dashboard-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-700);
margin: 8px 0 0 0;
}
.user-name {
font-weight: 700;
color: var(--color-primary-600);
}
/* Export Button */
.export-btn {
text-transform: none;
font-weight: 600;
font-size: 14px;
letter-spacing: 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.2);
}
/* Date Chip */
.date-chip {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid var(--color-neutral-500);
}
/* Stat Cards */
.stat-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 24px;
border: 2px solid transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
}
.stat-card-primary {
border-left-color: var(--color-primary-600);
}
.stat-card-secondary {
border-left-color: var(--color-secondary-600);
}
.stat-value {
font-size: 36px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1.4;
}
/* Chart Cards */
.chart-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--color-neutral-500);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
min-height: 480px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 16px 0;
margin-bottom: 16px;
}
.chart-title {
font-size: 18px;
font-weight: 700;
color: var(--color-neutral-900);
}
.period-toggle {
font-size: 12px;
font-weight: 600;
border-radius: 8px;
}
.year-chip {
cursor: pointer;
font-size: 13px;
font-weight: 700;
border-radius: 20px;
transition: all 0.2s ease;
}
.year-chip:hover {
transform: scale(1.05);
}
.live-chip {
font-weight: 600;
color: var(--color-neutral-100);
}
.chart-card .v-card-text {
flex: 1;
display: flex;
flex-direction: column;
}
/* Loading */
.loading-text {
margin-top: 16px;
color: var(--color-neutral-900);
font-weight: 600;
font-size: 16px;
}
/* Responsive */
@media (max-width: 960px) {
.dashboard-title {
font-size: 24px;
}
.dashboard-subtitle {
font-size: 14px;
}
.d-flex.justify-space-between {
flex-direction: column;
gap: 16px;
align-items: flex-start !important;
}
.d-flex.align-center .mr-4 {
margin-right: 0 !important;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
}
.stat-label {
font-size: 11px;
}
}
</style>
-1106
View File
File diff suppressed because it is too large Load diff
+293
View File
@@ -0,0 +1,293 @@
<template>
<v-main class="bg-grey-lighten-3">
<v-container fluid class="pa-6">
<!-- Colored Header Banner -->
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
<v-icon size="48" color="white" class="mr-4">mdi-clipboard-list-outline</v-icon>
<h1 class="text-h4 font-weight-bold text-white">Klinik Ruang Admin</h1>
</v-card>
<!-- Main Content Area -->
<v-row>
<v-col cols="12">
<v-card class="pa-6 rounded-xl elevation-2 mb-4">
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
GENERATE TIKET
</v-card-title>
<div class="d-flex align-center">
<v-text-field
label="Masukkan Barcode"
variant="solo"
density="compact"
hide-details
flat
class="mr-4 barcode-input"
v-model="barcodeInput"
@keyup.enter="generateTicket"
></v-text-field>
<v-chip color="#B71C1C" class="text-caption font-weight-bold text-white chip-warning">
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
</v-chip>
</div>
</v-card>
<v-card class="pa-6 rounded-xl elevation-2">
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
Pasien Klinik Ruang Admin
</v-card-title>
<div class="d-flex justify-space-between align-center my-3">
<div class="d-flex align-center">
<span>Show</span>
<v-select
density="compact"
variant="solo"
flat
:items="[10, 25, 50, 100]"
class="mx-2 select-items"
hide-details
v-model="itemsPerPage"
></v-select>
<span>entries</span>
</div>
<v-text-field
label="Search"
variant="solo"
density="compact"
flat
hide-details
append-inner-icon="mdi-magnify"
style="max-width: 200px"
v-model="searchQuery"
></v-text-field>
</div>
<v-table class="mt-3 custom-table">
<thead>
<tr>
<th v-for="header in klinikRuangAdminHeaders" :key="header.text">
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedItems.length === 0">
<td :colspan="klinikRuangAdminHeaders.length" class="text-center text-grey">
Tidak ada data yang tersedia
</td>
</tr>
<tr v-for="(item, index) in paginatedItems" :key="item.no">
<td>{{ (currentPage - 1) * itemsPerPage + index + 1 }}</td>
<td>{{ item.barcode }}</td>
<td>{{ item.noRekamedik }}</td>
<td>{{ item.noAntrian }}</td>
<td>{{ item.noAntrianKlinik }}</td>
<td>{{ item.noAntrianRuang }}</td>
<td>{{ item.shift }}</td>
<td>{{ item.pembayaran }}</td>
<td>
<v-btn color="success" size="small" class="text-white">Action</v-btn>
</td>
<td>{{ item.status }}</td>
</tr>
</tbody>
</v-table>
<div class="d-flex justify-space-between align-center mt-3 pa-2">
<span>Showing {{ showingFrom }} to {{ showingTo }} of {{ filteredItems.length }} entries</span>
<div class="d-flex">
<v-btn size="small" variant="flat" :disabled="currentPage === 1" @click="prevPage" class="pagination-btn">Previous</v-btn>
<v-btn size="small" variant="flat" :disabled="currentPage === totalPages" @click="nextPage" class="pagination-btn ml-2">Next</v-btn>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</template>
<script setup>
import { ref, computed, watch } from "vue";
definePageMeta({
middleware:['auth']
})
// === Data Dummy untuk Tabel ===
const klinikRuangAdminHeaders = [
{ text: 'No' },
{ text: 'Barcode' },
{ text: 'No Rekamedik' },
{ text: 'No Antrian' },
{ text: 'No Antrian Klinik' },
{ text: 'No Antrian Ruang' },
{ text: 'Shift' },
{ text: 'Pembayaran' },
{ text: 'Action' },
{ text: 'Status' },
];
const allItems = ref([
{ no: 1, barcode: '008730180085', noRekamedik: 'RM001', noAntrian: 'A001', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
{ no: 2, barcode: '008730180086', noRekamedik: 'RM002', noAntrian: 'A002', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Proses' },
{ no: 3, barcode: '008730180087', noRekamedik: 'RM003', noAntrian: 'A003', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Menunggu' },
{ no: 4, barcode: '008730180088', noRekamedik: 'RM004', noAntrian: 'A004', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
{ no: 5, barcode: '008730180089', noRekamedik: 'RM005', noAntrian: 'A005', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Proses' },
{ no: 6, barcode: '008730180090', noRekamedik: 'RM006', noAntrian: 'A006', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
{ no: 7, barcode: '008730180091', noRekamedik: 'RM007', noAntrian: 'A007', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Selesai' },
{ no: 8, barcode: '008730180092', noRekamedik: 'RM008', noAntrian: 'A008', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Proses' },
{ no: 9, barcode: '008730180093', noRekamedik: 'RM009', noAntrian: 'A009', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
{ no: 10, barcode: '008730180094', noRekamedik: 'RM010', noAntrian: 'A010', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Selesai' },
{ no: 11, barcode: '008730180095', noRekamedik: 'RM011', noAntrian: 'A011', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Proses' },
]);
// === State untuk Paginasi dan Pencarian ===
const itemsPerPage = ref(10);
const currentPage = ref(1);
const searchQuery = ref('');
const barcodeInput = ref('');
// === Computed Properties untuk Filter dan Paginasi ===
const filteredItems = computed(() => {
if (!searchQuery.value) {
return allItems.value;
}
const searchLower = searchQuery.value.toLowerCase();
return allItems.value.filter(item => {
return Object.values(item).some(value =>
String(value).toLowerCase().includes(searchLower)
);
});
});
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return filteredItems.value.slice(start, end);
});
const totalPages = computed(() => {
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
});
const showingFrom = computed(() => {
if (filteredItems.value.length === 0) return 0;
return (currentPage.value - 1) * itemsPerPage.value + 1;
});
const showingTo = computed(() => {
const end = currentPage.value * itemsPerPage.value;
return Math.min(end, filteredItems.value.length);
});
// === Fungsi untuk Paginasi dan Pencarian ===
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const generateTicket = () => {
// Logika untuk menambahkan data ke tabel
if (barcodeInput.value) {
// Hapus karakter non-digit jika ada
const cleanedBarcode = barcodeInput.value.replace(/\D/g, '');
const newNo = allItems.value.length + 1;
const newItem = {
no: newNo,
barcode: cleanedBarcode,
noRekamedik: `RM${String(newNo).padStart(3, '0')}`,
noAntrian: `A${String(newNo).padStart(3, '0')}`,
noAntrianKlinik: 'K1',
noAntrianRuang: 'R1',
shift: 'Pagi',
pembayaran: 'Tunai',
status: 'Menunggu'
};
allItems.value.unshift(newItem); // Tambahkan item baru di paling depan
barcodeInput.value = ''; // Reset input
currentPage.value = 1; // Kembali ke halaman pertama setelah menambahkan data
}
};
// === Watcher untuk mereset halaman saat filter atau items per page berubah ===
watch([searchQuery, itemsPerPage], () => {
currentPage.value = 1;
});
</script>
<style scoped>
/* Scoped styles to make the page more lively */
.v-container {
max-width: 1400px;
}
/* .header-banner {
background: linear-gradient(45deg, #42a5f5, #1565c0); /* Blue gradient */
/* color: white;
padding: 24px;
} */
.header-banner {
background: linear-gradient(45deg, #1A237E, #283593); /* Dark Blue gradient */
color: white;
padding: 24px;
}
.barcode-input .v-field--variant-solo {
background-color: #e0e0e0;
}
.chip-warning {
border-radius: 8px;
padding: 8px 12px;
}
.select-items {
max-width: 80px;
}
.select-items .v-field--variant-solo {
background-color: #e0e0e0;
}
.custom-table :deep(th) {
background-color: #e0e0e0;
font-weight: bold;
}
.custom-table :deep(tr) {
background-color: #f8f8f8;
}
.custom-table :deep(tbody tr:nth-of-type(odd)) {
background-color: #f1f1f1;
}
.pagination-btn {
margin: 0 4px;
background-color: #ffb38a !important;
}
.next-queue-card {
background: linear-gradient(135deg, #00A896, #00796B); /* Teal gradient */
}
.current-queue-number {
background-color: white;
border: 4px solid #00A896;
}
.text-primary {
color: #00A896 !important;
}
</style>
-635
View File
@@ -1,635 +0,0 @@
<template>
<div class="pasien-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-account-group</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">List Pasien</h1>
<p class="page-subtitle">Senin, 15 September 2025 - Data Master Pasien</p>
</div>
</div>
<div class="header-right">
<v-chip
color="success"
variant="flat"
class="mr-2"
>
Total {{ pasienData.length }} Pasien
</v-chip>
<v-chip
color="white"
variant="flat"
class="text-primary"
>
Status: Aktif
</v-chip>
</div>
</div>
</div>
<!-- Filter Controls
<v-card class="filter-controls-card mb-4" elevation="2">
<v-card-text class="py-4">
<v-row align="center">
<v-col cols="12" md="8">
<div class="d-flex align-center flex-wrap gap-10">
<span class="text-subtitle-1 font-weight-medium">Filter Cepat:</span>
<v-btn
color="primary"
variant="flat"
size="default"
class="px-4"
@click="filterByStatus('Tunggu Daftar')"
>
<v-icon start size="16">mdi-clock-outline</v-icon>
Tunggu Daftar
</v-btn>
<v-btn
color="success"
variant="flat"
size="default"
class="px-4"
@click="filterByStatus('Selesai')"
>
<v-icon start size="16">mdi-check-circle</v-icon>
Selesai
</v-btn>
<v-btn
color="warning"
variant="flat"
size="default"
class="px-4"
@click="filterByKlinik('JKN')"
>
<v-icon start size="16">mdi-card-account-details</v-icon>
JKN
</v-btn>
<v-btn
color="info"
variant="flat"
size="default"
class="px-4"
@click="resetFilter()"
>
<v-icon start size="16">mdi-refresh</v-icon>
Reset
</v-btn>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="d-flex justify-end gap-2">
<v-btn
color="success"
variant="flat"
@click="handleExportLaporan"
:loading="loading"
>
<v-icon start>mdi-file-excel</v-icon>
Export Laporan
</v-btn>
<v-btn
color="primary"
variant="flat"
@click="handleExportLaporanPerKlinik"
:loading="loading"
>
<v-icon start>mdi-hospital-building</v-icon>
Export Per Klinik
</v-btn>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card> -->
<!-- Main Patients Table -->
<v-card class="main-table-card mb-4" elevation="2">
<v-card-title class="d-flex align-center justify-space-between pa-6">
<div class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-table</v-icon>
<span class="text-h6 font-weight-bold">DATA PASIEN</span>
</div>
<v-chip color="info" variant="flat">
{{ filteredData.length }} dari {{ pasienData.length }} pasien
</v-chip>
</v-card-title>
<v-divider></v-divider>
<TabelListPasien
:items="filteredData"
@search="handleSearch"
@export-laporan="handleExportLaporan"
@export-laporan-per-klinik="handleExportLaporanPerKlinik"
/>
</v-card>
Statistics Cards
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="success" class="mb-2">mdi-account-check</v-icon>
<div class="text-h4 font-weight-bold text-success">{{ getStatsByStatus('Tunggu Daftar') }}</div>
<div class="text-body-2 text-grey-darken-1">Tunggu Daftar</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="info" class="mb-2">mdi-barcode</v-icon>
<div class="text-h4 font-weight-bold text-info">{{ getStatsByStatus('Barcode') }}</div>
<div class="text-body-2 text-grey-darken-1">Barcode</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="warning" class="mb-2">mdi-wifi</v-icon>
<div class="text-h4 font-weight-bold text-warning">{{ getStatsByKeterangan('Online') }}</div>
<div class="text-body-2 text-grey-darken-1">Online</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="error" class="mb-2">mdi-wifi-off</v-icon>
<div class="text-h4 font-weight-bold text-error">{{ getStatsByKeterangan('Offline') }}</div>
<div class="text-body-2 text-grey-darken-1">Offline</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Loading Overlay -->
<v-overlay v-model="loading" class="align-center justify-center">
<v-progress-circular
color="primary"
indeterminate
size="64"
/>
</v-overlay>
<!-- Snackbar -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top right"
>
{{ snackbar.message }}
<template v-slot:actions>
<v-btn
icon
@click="snackbar.show = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import TabelListPasien from '~/components/TabelListPasien.vue'
// Meta untuk SEO
definePageMeta({
title: 'List Pasien',
layout: 'default'
})
// Reactive states
const loading = ref(false)
const currentFilter = ref('')
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
// Sample data
const pasienData = ref([
{
tglPeriksa: '27/08/2025',
nik: '3507264104730004',
rm: '11412584',
barcode: '250627100001',
noAntrian: 'HQ1001',
klinik: 'HOM',
fullName: 'Binti Almatul',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3504096063630001',
rm: '',
barcode: '250627100002',
noAntrian: 'QB1001',
klinik: 'KANDUNGAN',
fullName: 'maret kumalal',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3507114102250002',
rm: '11555560',
barcode: '250627100003',
noAntrian: 'QB1002',
klinik: 'KANDUNGAN',
fullName: 'ayu rafti lelu amanda',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3508185040150002',
rm: '11333655',
barcode: '250627100004',
noAntrian: 'AN1001',
klinik: 'ANAK',
fullName: 'Erin Wahyuni',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3515085040110004',
rm: '11585554',
barcode: '250627100005',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: 'Yohana Karina Pusplta Sari',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3506246105750002',
rm: '11527608',
barcode: '250627100006',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: 'Elok Suharsti',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100007',
noAntrian: 'HQ1002',
klinik: 'HOM',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100008',
noAntrian: 'IP1002',
klinik: 'IPD',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100009',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100010',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
}
])
// Computed properties
const filteredData = computed(() => {
if (!currentFilter.value) {
return pasienData.value
}
return pasienData.value.filter(item => {
return item.status === currentFilter.value ||
item.pembayaran === currentFilter.value ||
item.keterangan === currentFilter.value
})
})
// Methods
const showSnackbar = (message, color = 'success') => {
snackbar.value = {
show: true,
message,
color
}
}
const filterByStatus = (status) => {
currentFilter.value = status
showSnackbar(`Filter diterapkan: ${status}`, 'info')
}
const filterByKlinik = (pembayaran) => {
currentFilter.value = pembayaran
showSnackbar(`Filter diterapkan: ${pembayaran}`, 'info')
}
const resetFilter = () => {
currentFilter.value = ''
showSnackbar('Filter direset', 'success')
}
const getStatsByStatus = (status) => {
return pasienData.value.filter(item => item.status === status).length
}
const getStatsByKeterangan = (keterangan) => {
return pasienData.value.filter(item => item.keterangan === keterangan).length
}
const handleSearch = async (filters) => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Search filters:', filters)
showSnackbar('Data berhasil difilter', 'success')
} catch (error) {
console.error('Error searching data:', error)
showSnackbar('Gagal memfilter data', 'error')
} finally {
loading.value = false
}
}
const handleExportLaporan = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 2000))
console.log('Exporting laporan pasien...')
showSnackbar('Laporan pasien berhasil diexport', 'success')
} catch (error) {
console.error('Error exporting laporan:', error)
showSnackbar('Gagal export laporan pasien', 'error')
} finally {
loading.value = false
}
}
const handleExportLaporanPerKlinik = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 2000))
console.log('Exporting laporan pasien per klinik...')
showSnackbar('Laporan pasien per klinik berhasil diexport', 'success')
} catch (error) {
console.error('Error exporting laporan per klinik:', error)
showSnackbar('Gagal export laporan pasien per klinik', 'error')
} finally {
loading.value = false
}
}
const fetchPasienData = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Patient data loaded successfully')
} catch (error) {
console.error('Error fetching patient data:', error)
showSnackbar('Gagal memuat data pasien', 'error')
} finally {
loading.value = false
}
}
// Lifecycle
onMounted(() => {
fetchPasienData()
})
// Head untuk SEO
useHead({
title: 'List Pasien - Antrean RSSA',
meta: [
{
name: 'description',
content: 'Daftar master data seluruh pasien rumah sakit'
}
]
})
</script>
<style scoped>
.pasien-container {
background: #f5f7fa;
min-height: 100vh;
padding: 20px;
}
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: white;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
}
/* .filter-controls-card, */
.main-table-card,
.stats-card {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.stats-card {
transition: all 0.2s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
/* Enhanced table styling */
.main-table-card :deep(.v-data-table th) {
background: #fafbfc;
font-weight: 600;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.main-table-card :deep(.v-data-table tbody tr:hover) {
background: #f8fafc !important;
}
.main-table-card :deep(.v-data-table tbody td) {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
/* Button styling */
.v-btn {
text-transform: none !important;
font-weight: 500;
}
.v-btn--size-default {
height: 40px;
}
/* Responsive Design */
@media (max-width: 1024px) {
.header-content {
flex-direction: column;
gap: 20px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.page-title {
font-size: 28px;
}
}
@media (max-width: 768px) {
.pasien-container {
padding: 16px;
}
.header-content {
padding: 24px 20px;
}
.page-title {
font-size: 24px;
}
.header-icon {
padding: 12px;
}
/* .filter-controls-card .d-flex {
flex-direction: column;
align-items: flex-start;
gap: 16px;
} */
.filter-controls-card .v-col:last-child .d-flex {
justify-content: flex-start;
}
}
@media (max-width: 600px) {
.page-title {
font-size: 20px;
}
.filter-buttons-container {
justify-content: center;
}
.filter-btn {
margin-right: 12px;
margin-bottom: 10px;
}
.header-right .v-chip {
font-size: 12px;
}
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<v-container fluid fill-height class="login-background">
<div class="floating-medical-icon icon-1">
<v-icon size="144">mdi-heart-pulse</v-icon>
</div>
<div class="floating-medical-icon icon-2">
<v-icon size="108">mdi-medical-bag</v-icon>
</div>
<div class="floating-medical-icon icon-3">
<v-icon size="126">mdi-stethoscope</v-icon>
</div>
<div class="floating-medical-icon icon-4">
<v-icon size="120">mdi-hospital-box</v-icon>
</div>
<div class="floating-medical-icon icon-5">
<v-icon size="96">mdi-pill</v-icon>
</div>
<div class="floating-medical-icon icon-6">
<v-icon size="114">mdi-bandage</v-icon>
</div>
<v-row class="fill-height align-center justify-center">
<v-col cols="12" class="d-flex justify-center align-center">
<v-card class="main-card white-card rounded-xl pa-0" max-width="450" width="100%">
<v-row class="ma-0">
<v-col cols="12" class="login-section-box d-flex align-center pa-8">
<div class="login-content w-100">
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark">Antrean RSSA</span>
<img
src="/AntreanLogobg.png"
alt="Antrean Logo"
class="mt-3 hospital-logo"
/>
</div>
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<v-divider class="my-6 custom-divider-dark"></v-divider>
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<!-- <div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#FF9B1B" size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div> -->
<!-- <div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div> -->
</div>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
<!-- <v-dialog v-model="showRegistrationDialog" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#FF9B1B">mdi-account-plus</v-icon> Pendaftaran Akun Baru
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-information</v-icon> </div>
<p class="text-body-1 mb-4">
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
</p>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-numeric-1-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Hubungi Administrator IT Rumah Sakit
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-numeric-2-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Siapkan dokumen identitas dan surat penugasan
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-numeric-3-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Tunggu proses verifikasi dan aktivasi akun
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@click="showRegistrationDialog = false"
color="grey"
variant="outlined"
rounded
>
Tutup
</v-btn>
<v-btn
@click="showRegistrationDialog = false; showAdminContact = true"
color="#FF9B1B" rounded
>
<v-icon left>mdi-phone</v-icon>
Hubungi Admin
</v-btn>
</v-card-actions>
</v-card>
</v-dialog> -->
<!-- <v-dialog v-model="showAdminContact" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#FF9B1B">mdi-account-tie</v-icon> Kontak Administrator
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-phone-settings</v-icon> </div>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-office-building</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
IT Support RSSA
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Bagian Teknologi Informasi
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-phone</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
(0341) 343343 ext. 1234
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Telepon Internal
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-email</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
it-support@rssa.malang.go.id
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Email Resmi
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#FF9B1B">mdi-clock</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
Senin - Jumat: 07:00 - 15:00
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Jam Operasional
</v-list-item-subtitle>
</div>
</v-list-item>
</v-list>
<v-alert
type="info"
variant="tonal"
class="mt-4"
color="orange" >
<div class="text-grey-darken-2">
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
</div>
</v-alert>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@click="showAdminContact = false"
color="grey"
variant="outlined"
rounded
>
Tutup
</v-btn>
<v-btn
@click="copyContactInfo"
color="#FF9B1B" rounded
>
<v-icon left>mdi-content-copy</v-icon>
Salin Info
</v-btn>
</v-card-actions>
</v-card>
</v-dialog> -->
</v-container>
</template>
<script setup lang="ts">
import type { LoginResponse } from '~/types/auth'
// Use guest middleware to redirect if already authenticated
definePageMeta({
layout: 'empty',
middleware:['auth','guest']
})
// Reactive state
const isLoading = ref<boolean>(false)
const errorMessage = ref<string>('')
const successMessage = ref<string>('')
const showRegistrationDialog = ref<boolean>(false)
const showAdminContact = ref<boolean>(false)
// Hospital Profile URL
const HOSPITAL_PROFILE_URL = 'https://rsusaifulanwar.jatimprov.go.id/v2/'
// Function to navigate to external hospital profile site
const goToHospitalProfile = (): void => {
window.open(HOSPITAL_PROFILE_URL, '_blank')
}
// Check URL parameters for errors
const route = useRoute()
onMounted(() => {
if (route.query.error) {
errorMessage.value = decodeURIComponent(route.query.error as string)
}
})
// Custom login handler (pointing to Keycloak SSO logic)
const handleLogin = async (): Promise<void> => {
isLoading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
console.log('Starting login process...')
// Call API route to initiate Keycloak login process
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('Redirecting to Keycloak...')
successMessage.value = 'Redirecting to Keycloak...'
// Redirect the user to the Keycloak authorization URL
setTimeout(() => {
window.location.href = response.data!.authUrl
}, 500)
} else {
throw new Error('Failed to get authorization URL')
}
} catch (error: any) {
console.error('Login error:', error)
// Display the error message
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
} finally {
// Only set isLoading back to false if no redirect is happening
if (!successMessage.value.includes('Redirecting')) {
isLoading.value = false
}
}
}
// Copy contact information to clipboard
const copyContactInfo = async (): Promise<void> => {
// Updated contact info block from user's provided code
const contactInfo = `
IT Support RSSA
Telepon: (0341) 343343 ext. 1234
Email: it-support@rssa.malang.go.id
Jam Operasional: Senin - Jumat, 07:00 - 15:00
`.trim()
try {
// Use the Clipboard API
await navigator.clipboard.writeText(contactInfo)
successMessage.value = 'Informasi kontak berhasil disalin!'
showAdminContact.value = false
// Clear the success message after a short delay
setTimeout(() => {
successMessage.value = ''
}, 3000)
} catch (error) {
console.error('Failed to copy contact info:', error)
// Fallback or simple alert for error
errorMessage.value = 'Gagal menyalin informasi kontak. Silakan coba salin manual.'
setTimeout(() => {
errorMessage.value = ''
}, 3000)
}
}
</script>
<style scoped src="~/assets/styles/loginpage/login.css"></style>
-754
View File
@@ -1,754 +0,0 @@
<!-- pages/LoketAdmin.vue -->
<template>
<div class="loket-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Loket Admin</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
</div>
</div>
<div class="header-right">
<v-chip color="success" variant="flat" class="mr-2">
Total {{ totalPasien }} Pasien
</v-chip>
<v-chip color="white" variant="flat" class="text-primary">
Max 150 Pasien
</v-chip>
</div>
</div>
</div>
<div>
<v-card class="next-patient-card mb-4" elevation="2">
<v-card-text class="text-center pa-6">
<v-chip color="success" variant="flat" class="mb-3" size="large">
<v-icon start>mdi-account-arrow-right</v-icon>
PASIEN SELANJUTNYA
</v-chip>
<div class="text-h4 font-weight-bold mb-2 text-success">
{{ nextPatient ? nextPatient.noAntrian.split(" |")[0] : "UM1004" }}
</div>
<div class="text-body-1 mb-4 text-grey-darken-1">
Klik tombol hijau untuk memanggil
</div>
<v-btn
color="success"
variant="flat"
size="large"
class="px-8"
@click="callNext"
:disabled="!nextPatient"
>
<v-icon start>mdi-microphone</v-icon>
PANGGIL NEXT
</v-btn>
</v-card-text>
</v-card>
</div>
<!-- Combined Control Section -->
<v-row class="mb-4">
<!-- Left Side: Call Controls and Current Processing -->
<v-col cols="12" lg="6">
<!-- Current Patient Processing Card -->
<v-card
v-if="currentProcessingPatient"
class="current-processing-card"
elevation="2"
>
<v-card-text class="pa-4">
<div class="d-flex align-center justify-space-between">
<div class="patient-info">
<v-chip color="primary" variant="flat" class="mb-2">
<v-icon start>mdi-account-clock</v-icon>
SEDANG DIPROSES
</v-chip>
<div class="text-h5 font-weight-bold mb-1">
{{ currentProcessingPatient.noAntrian.split(" |")[0] }}
</div>
<div class="text-subtitle-1 text-grey-darken-1">
{{ currentProcessingPatient.barcode }} |
{{ currentProcessingPatient.klinik }}
</div>
</div>
<div class="action-buttons">
<v-btn
color="success"
variant="flat"
class="mr-2"
@click="processPatient(currentProcessingPatient, 'check-in')"
>
<v-icon start>mdi-check-circle</v-icon>
Check In
</v-btn>
<v-btn
color="warning"
variant="flat"
class="mr-2"
@click="processPatient(currentProcessingPatient, 'terlambat')"
>
<v-icon start>mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn
color="error"
variant="flat"
@click="processPatient(currentProcessingPatient, 'pending')"
>
<v-icon start>mdi-pause-circle</v-icon>
Pending
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Right Side: Patient Queue Info -->
<v-col cols="12" lg="6">
<!-- Next Patient Card -->
<!-- Quota Info Card -->
<v-card class="quota-info-card" elevation="2">
<v-card-text class="pa-4">
<div class="text-center">
<div class="text-h6 font-weight-medium mb-3">
Panggil Antrean Anjungan
</div>
<v-row class="mb-3">
<v-col cols="6" class="text-center">
<div class="text-caption text-grey-darken-1">Kuota</div>
<div class="text-h4 font-weight-bold">150</div>
</v-col>
<v-col cols="6" class="text-center">
<div class="text-caption text-grey-darken-1">Tersedia</div>
<div class="text-h4 font-weight-bold text-success">
{{ 150 - quotaUsed }}
</div>
</v-col>
</v-row>
<div class="text-body-2 text-grey-darken-1 mb-2">
Total Quota Terpakai: {{ quotaUsed }}
</div>
<v-progress-linear
:model-value="(quotaUsed / 150) * 100"
color="success"
height="8"
rounded
></v-progress-linear>
</div>
</v-card-text>
</v-card>
<v-card-text class="call-controls-card align-center py-4">
<div class="text-subtitle-1 font-weight-medium"
>Panggil Pasien:</div>
<div class="d-flex align-center flex-wrap mb-3">
<v-btn
color="success"
variant="flat"
size="large"
class="px-6 ma-4"
@click="callMultiplePatients(1)"
>
<span class="text-h6 font-weight-bold">1</span>
</v-btn>
<v-btn
color="info"
variant="flat"
size="large"
class="px-6 ma-4"
@click="callMultiplePatients(5)"
>
<span class="text-h6 font-weight-bold">5</span>
</v-btn>
<v-btn
color="warning"
variant="flat"
size="large"
class="px-6 ma-4"
@click="callMultiplePatients(10)"
>
<span class="text-h6 font-weight-bold">10</span>
</v-btn>
<v-btn
color="error"
variant="flat"
size="large"
class="px-6 ma-4"
@click="callMultiplePatients(20)"
>
<span class="text-h6 font-weight-bold">20</span>
</v-btn>
</div>
</v-card-text>
</v-col>
</v-row>
<!-- Di Loket Patients Table -->
<v-card class="main-table-card mb-4" elevation="2">
<TabelData
:headers="diLoketHeaders"
:items="diLoketPatients"
title="DATA PASIEN - DI LOKET"
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
size="small"
color="info"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
<v-btn
size="small"
color="success"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
</div>
</template>
<template #item.jamPanggil="{ item }">
<span :class="getRowClass(item)">{{ item.jamPanggil }}</span>
</template>
</TabelData>
</v-card>
<!-- Terlambat Patients Table -->
<v-card
class="late-table-card mb-4"
elevation="2"
v-if="terlambatPatients.length > 0"
>
<TabelData
:headers="terlambatHeaders"
:items="terlambatPatients"
title="INFO PASIEN LAPOR TERLAMBAT"
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
size="small"
color="success"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
</div>
</template>
</TabelData>
</v-card>
<!-- Pending Patients Table -->
<v-card
class="pending-table-card mb-4"
elevation="2"
v-if="pendingPatients.length > 0"
>
<TabelData
:headers="pendingHeaders"
:items="pendingPatients"
title="INFO PASIEN PENDING"
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
size="small"
color="success"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
</div>
</template>
</TabelData>
</v-card>
<!-- Snackbar -->
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
location="top right"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn icon @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import TabelData from "../components/TabelData.vue";
// Reactive data
const snackbar = ref(false);
const snackbarText = ref("");
const snackbarColor = ref("success");
const quotaUsed = ref(5);
const currentProcessingPatient = ref(null);
// Base patient data - semua pasien yang belum dipanggil
const allPatients = ref([
{
no: 1,
jamPanggil: "12:49",
barcode: "250811100163",
noAntrian: "UM1001 | Online - 250811100163",
shift: "Shift 1",
klinik: "KANDUNGAN",
fastTrack: "UMUM",
pembayaran: "UMUM",
status: "waiting", // waiting, di-loket, terlambat, pending, processed
},
{
no: 2,
jamPanggil: "10:52",
barcode: "250811100155",
noAntrian: "UM1002 | Online - 250811100155",
shift: "Shift 1",
klinik: "IPD",
fastTrack: "UMUM",
pembayaran: "UMUM",
status: "waiting",
},
{
no: 3,
jamPanggil: "09:30",
barcode: "250811100200",
noAntrian: "UM1003 | Online - 250811100200",
shift: "Shift 1",
klinik: "SARAF",
fastTrack: "UMUM",
pembayaran: "UMUM",
status: "waiting",
},
{
no: 4,
jamPanggil: "14:15",
barcode: "250811100210",
noAntrian: "UM1004 | Online - 250811100210",
shift: "Shift 1",
klinik: "THT",
fastTrack: "UMUM",
pembayaran: "UMUM",
status: "waiting",
},
...Array.from({ length: 16 }, (_, i) => ({
no: i + 5,
jamPanggil: `${String(Math.floor(Math.random() * 12) + 1).padStart(2, "0")}:${String(Math.floor(Math.random() * 60)).padStart(2, "0")}`,
barcode: `25081110${String(i + 300).padStart(4, "0")}`,
noAntrian: `UM100${i + 5} | Online - 25081110${String(i + 300).padStart(4, "0")}`,
shift: "Shift 1",
klinik: ["KANDUNGAN", "IPD", "THT", "SARAF"][Math.floor(Math.random() * 4)],
fastTrack: "UMUM",
pembayaran: "UMUM",
status: "waiting",
})),
]);
// Computed properties for different status tables
const diLoketPatients = computed(() =>
allPatients.value.filter((patient) => patient.status === "di-loket")
);
const terlambatPatients = computed(() =>
allPatients.value.filter((patient) => patient.status === "terlambat")
);
const pendingPatients = computed(() =>
allPatients.value.filter((patient) => patient.status === "pending")
);
const nextPatient = computed(() => {
return allPatients.value.find((patient) => patient.status === "waiting");
});
const totalPasien = computed(() => allPatients.value.length);
// Headers for different tables
const diLoketHeaders = ref([
{ title: "No", value: "no", sortable: false, width: "60px" },
{ title: "Jam Panggil", value: "jamPanggil", sortable: true, width: "100px" },
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
{ title: "Fast Track", value: "fastTrack", sortable: true, width: "100px" },
{ title: "Pembayaran", value: "pembayaran", sortable: true, width: "100px" },
{ title: "Aksi", value: "aksi", sortable: false, width: "200px" },
]);
const terlambatHeaders = ref([
{ title: "No", value: "no", sortable: false, width: "60px" },
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
{ title: "Aksi", value: "aksi", sortable: false, width: "100px" },
]);
const pendingHeaders = ref([
{ title: "#", value: "no", sortable: false, width: "60px" },
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
{ title: "Fast Track", value: "fastTrack", sortable: true, width: "100px" },
{ title: "Pembayaran", value: "pembayaran", sortable: true, width: "100px" },
{ title: "Aksi", value: "aksi", sortable: false, width: "100px" },
]);
// Methods
const showSnackbar = (text, color = "success") => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
const callMultiplePatients = (count) => {
const waitingPatients = allPatients.value.filter(
(patient) => patient.status === "waiting"
);
const patientsToCall = waitingPatients.slice(0, count);
if (patientsToCall.length === 0) {
showSnackbar("Tidak ada pasien yang menunggu", "warning");
return;
}
// Check quota
if (quotaUsed.value + patientsToCall.length > 150) {
showSnackbar("Quota tidak mencukupi", "error");
return;
}
// Move patients to "di-loket" status
patientsToCall.forEach((patient) => {
patient.status = "di-loket";
});
quotaUsed.value += patientsToCall.length;
showSnackbar(`Memanggil ${patientsToCall.length} pasien ke loket`, "success");
};
const callNext = () => {
if (!nextPatient.value) {
showSnackbar("Tidak ada pasien selanjutnya", "warning");
return;
}
if (quotaUsed.value >= 150) {
showSnackbar("Quota sudah penuh", "error");
return;
}
// Move next patient to processing
nextPatient.value.status = "di-loket";
currentProcessingPatient.value = nextPatient.value;
quotaUsed.value++;
showSnackbar(
`Memanggil pasien ${nextPatient.value.noAntrian.split(" |")[0]}`,
"success"
);
};
const processPatient = (patient, action) => {
const patientCode = patient.noAntrian.split(" |")[0];
switch (action) {
case "check-in":
patient.status = "processed";
if (currentProcessingPatient.value?.no === patient.no) {
currentProcessingPatient.value = null;
}
showSnackbar(`Pasien ${patientCode} berhasil check in`, "success");
break;
case "terlambat":
patient.status = "terlambat";
if (currentProcessingPatient.value?.no === patient.no) {
currentProcessingPatient.value = null;
}
showSnackbar(`Pasien ${patientCode} ditandai terlambat`, "warning");
break;
case "pending":
patient.status = "pending";
if (currentProcessingPatient.value?.no === patient.no) {
currentProcessingPatient.value = null;
}
showSnackbar(`Pasien ${patientCode} di-pending`, "info");
break;
case "aktifkan":
if (patient.status === "terlambat") {
patient.status = "di-loket";
showSnackbar(`Pasien ${patientCode} diaktifkan kembali`, "success");
}
break;
case "proses":
currentProcessingPatient.value = patient;
showSnackbar(`Memproses pasien ${patientCode}`, "info");
break;
}
};
const getRowClass = (item) => {
if (item.status === "current") {
return "text-success font-weight-bold";
}
return "";
};
</script>
<style scoped>
.loket-container {
background: #f5f7fa;
min-height: 100vh;
padding: 20px;
}
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: white;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
}
.call-controls-card,
.next-patient-card,
.current-processing-card,
.quota-info-card,
.main-table-card,
.late-table-card,
.pending-table-card {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.next-patient-card {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 2px solid rgba(76, 175, 80, 0.2);
}
.current-processing-card {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
border: 2px solid rgba(255, 152, 0, 0.2);
}
.quota-info-card {
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
border: 2px solid rgba(33, 150, 243, 0.2);
}
.patient-info .text-h5 {
color: #1976d2;
}
.action-buttons {
display: flex;
align-items: center;
}
/* Enhanced table styling */
.main-table-card :deep(.v-data-table th),
.late-table-card :deep(.v-data-table th),
.pending-table-card :deep(.v-data-table th) {
background: #fafbfc;
font-weight: 600;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.main-table-card :deep(.v-data-table tbody tr:hover),
.late-table-card :deep(.v-data-table tbody tr:hover),
.pending-table-card :deep(.v-data-table tbody tr:hover) {
background: #f8fafc !important;
}
.main-table-card :deep(.v-data-table tbody td),
.late-table-card :deep(.v-data-table tbody td),
.pending-table-card :deep(.v-data-table tbody td) {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
/* Button styling */
.v-btn {
text-transform: none !important;
}
.v-btn--size-small {
height: 32px;
padding: 0 12px;
}
/* Success text color */
.text-success {
color: #4caf50 !important;
}
/* Responsive Design */
@media (max-width: 1024px) {
.header-content {
flex-direction: column;
gap: 20px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.page-title {
font-size: 28px;
}
.current-processing-card .d-flex {
flex-direction: column;
gap: 20px;
align-items: flex-start;
}
.action-buttons {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.action-buttons .v-btn {
flex: 1;
min-width: 120px;
}
/* Stack layout vertically on medium screens */
.call-controls-card .d-flex {
justify-content: center;
}
}
@media (max-width: 768px) {
.loket-container {
padding: 16px;
}
.header-content {
padding: 24px 20px;
}
.page-title {
font-size: 24px;
}
.header-icon {
padding: 12px;
}
.call-controls-card .d-flex {
flex-direction: column;
align-items: center;
gap: 16px;
}
.call-controls-card .v-btn {
min-width: 80px;
}
/* Stack all control buttons vertically on mobile */
.call-controls-card .d-flex.flex-wrap {
flex-direction: column;
align-items: stretch;
}
.call-controls-card .v-btn {
width: 100%;
margin: 4px 0;
}
.action-buttons {
flex-direction: column;
width: 100%;
}
.action-buttons .v-btn {
width: 100%;
margin: 4px 0;
}
}
@media (max-width: 600px) {
.page-title {
font-size: 20px;
}
.next-patient-card .text-h4 {
font-size: 1.75rem !important;
}
.current-processing-card .patient-info .text-h5 {
font-size: 1.25rem !important;
}
.quota-info-card .text-h4 {
font-size: 1.5rem !important;
}
}
</style>
+252
View File
@@ -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>
+298
View File
@@ -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>
+786
View File
@@ -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>
+175
View File
@@ -0,0 +1,175 @@
<template>
<v-main class="ranap-admin-page">
<v-container fluid class="pa-6">
<!-- Modern Header -->
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
<v-icon size="48" color="white" class="mr-4">mdi-hospital-box-outline</v-icon>
<h1 class="text-h4 font-weight-bold text-white">Ranap Admin</h1>
</v-card>
<v-card class="elevation-2 rounded-xl pa-4">
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex align-center">
<span class="text-caption mr-2">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
hide-details
variant="solo"
flat
class="pagination-select"
:menu-props="{ attach: true }"
></v-select>
<span class="text-caption ml-2">entries</span>
</div>
<v-text-field
v-model="search"
label="Search"
append-inner-icon="mdi-magnify"
single-line
hide-details
density="compact"
variant="solo"
flat
class="search-field"
></v-text-field>
</div>
<v-data-table
:headers="headers"
:items="filteredItems"
:items-per-page="itemsPerPage"
class="elevation-0"
item-key="noAntrean"
>
<template v-slot:item.aksi="{ item }">
<v-btn small color="success" class="text-white" @click="selectItem(item)">
Selesai
</v-btn>
</template>
<template v-slot:bottom>
<div class="d-flex justify-space-between align-center pa-2">
<span class="text-caption">Showing {{ (page - 1) * itemsPerPage + 1 }} to {{ Math.min(page * itemsPerPage, filteredItems.length) }} of {{ filteredItems.length }} entries</span>
<v-pagination
v-model="page"
:length="pageCount"
:total-visible="5"
rounded="circle"
></v-pagination>
</div>
</template>
</v-data-table>
</v-card>
<v-row class="mt-6">
<v-col cols="12" md="4" class="pr-md-2">
<v-card class="elevation-2 rounded-xl next-card pa-4 d-flex flex-column align-center text-center">
<h2 class="text-h3 font-weight-bold next-title">NEXT</h2>
<span class="text-h4 font-weight-medium my-4 next-content">{{ nextAntrean || 'Kosong' }}</span>
<p class="text-caption">Klik untuk memanggil pasien selanjutnya</p>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
const headers = [
{ title: 'No', align: 'start', sortable: false, key: 'no' },
{ title: 'No. Antrean', key: 'noAntrean' },
{ title: 'Daftar', key: 'daftar' },
{ title: 'Pelayanan', key: 'pelayanan' },
{ title: 'Aksi', key: 'aksi' },
];
const items = ref([
{ no: 1, noAntrean: '001', daftar: '26 Aug 2025 07:10:31', pelayanan: 'Belum Dilayani' },
{ no: 2, noAntrean: '002', daftar: '26 Aug 2025 07:10:35', pelayanan: 'Belum Dilayani' },
{ no: 3, noAntrean: '003', daftar: '26 Aug 2025 07:10:44', pelayanan: 'Belum Dilayani' },
{ no: 4, noAntrean: '004', daftar: '26 Aug 2025 07:10:46', pelayanan: 'Belum Dilayani' },
{ no: 5, noAntrean: '005', daftar: '26 Aug 2025 07:10:47', pelayanan: 'Belum Dilayani' },
{ no: 6, noAntrean: '006', daftar: '26 Aug 2025 07:10:49', pelayanan: 'Belum Dilayani' },
{ no: 7, noAntrean: '007', daftar: '26 Aug 2025 07:10:51', pelayanan: 'Belum Dilayani' },
{ no: 8, noAntrean: '008', daftar: '26 Aug 2025 07:10:53', pelayanan: 'Belum Dilayani' },
{ no: 9, noAntrean: '009', daftar: '26 Aug 2025 07:10:54', pelayanan: 'Belum Dilayani' },
{ no: 10, noAntrean: '010', daftar: '26 Aug 2025 07:10:55', pelayanan: 'Belum Dilayani' },
]);
const search = ref('');
const itemsPerPage = ref(10);
const page = ref(1);
const nextAntrean = ref('001');
const filteredItems = computed(() => {
if (!search.value) {
return items.value;
}
return items.value.filter(item =>
Object.values(item).some(val =>
String(val).toLowerCase().includes(search.value.toLowerCase())
)
);
});
const pageCount = computed(() => {
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
});
const selectItem = (item) => {
console.log('Item selected:', item);
// Di sini Anda bisa menambahkan logika untuk mengubah status pasien menjadi "Dilayani" atau memindahkannya ke antrean berikutnya.
};
</script>
<style scoped>
.ranap-admin-page {
background-color: #f5f5f5;
min-height: 100vh;
}
.header-banner {
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
color: white;
padding: 24px;
}
.search-field, .pagination-select {
max-width: 250px;
}
.v-data-table :deep(table) {
border-collapse: separate;
border-spacing: 0 10px;
}
.v-data-table :deep(tbody tr) {
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
.next-card {
background-color: #00A896;
color: white;
height: 200px;
}
.next-title {
font-size: 3.5rem;
}
.next-content {
font-size: 3rem;
line-height: 1;
}
</style>
File diff suppressed because it is too large Load diff
-156
View File
@@ -1,156 +0,0 @@
<template>
<v-container>
<v-card>
<!-- <v-card-title class="d-flex justify-space-between align-center">
<span>Master Loket</span>
<v-btn color=#ff9248 @click="tambahLoket" style="color:white;">Tambah Baru</v-btn>
</v-card-title> -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Master Loket</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
</div>
</div>
</div>
</div>
<TabelData
:headers="loketHeaders"
:items="loketData"
title="Master Loket"
>
<template #actions="{ item }">
<v-btn
small
color="#ff9248"
@click="editLoket(item)"
class="mr-2"
style="color: white"
>Edit</v-btn
>
<v-btn small color="grey-lighten-4" @click="deleteLoket(item)"
>Delete</v-btn
>
</template>
</TabelData>
</v-card>
</v-container>
</template>
<script setup>
import { ref } from "vue";
import TabelData from "../../components/TabelData.vue";
import { useRouter } from "vue-router";
const router = useRouter();
const loketHeaders = ref([
{ title: "No", value: "no" },
{ title: "Nama Loket", value: "namaLoket" },
{ title: "Kuota", value: "kuota" },
{ title: "Pelayanan", value: "pelayanan" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Keterangan", value: "keterangan" },
{ title: "Aksi", value: "aksi", sortable: false }, // 'value' harus 'actions'
]);
// Master-Loket.vue
const loketData = ref([
{
id: 1,
no: 1,
namaLoket: "Loket 1",
kuota: 500,
pelayanan: ["RADIOTERAPI", "REHAB MEDIK", "TINDAKAN"],
pembayaran: "JKN",
keterangan: "ONLINE",
},
{
id: 2,
no: 2,
namaLoket: "Loket 2",
kuota: 666,
pelayanan: ["JIWA", "SARAF"],
pembayaran: "JKN",
keterangan: "ONLINE",
},
{
id: 3,
no: 3,
namaLoket: "Loket 3",
kuota: 666,
pelayanan: ["ANESTESI", "JANTUNG"],
pembayaran: "JKN",
keterangan: "ONLINE",
},
{
id: 4,
no: 4,
namaLoket: "Loket 4",
kuota: 3676,
pelayanan: ["KULIT KELAMIN", "PARU"],
pembayaran: "JKN",
keterangan: "ONLINE",
},
]);
const editLoket = (item) => {
router.push({ path: `/Setting/Edit-Loket/${item.id}` });
};
const deleteLoket = (item) => {
const index = loketData.value.findIndex((loket) => loket.id === item.id);
if (index !== -1) {
loketData.value.splice(index, 1);
}
};
const tambahLoket = () => {
router.push({ path: "/Setting/Tambah-Loket" });
};
</script>
<style scoped>
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: white;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
</style>
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more