Compare commits
175
Commits
main
...
Antrean-Code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8745b22f07 | ||
|
|
b0b5200802 | ||
|
|
fec58f14b5 | ||
|
|
bbcaf9826c | ||
|
|
d0eee7d062 | ||
|
|
61772105fd | ||
|
|
e18ed186e1 | ||
|
|
400bfcdcaf | ||
|
|
8b9c4725de | ||
|
|
f81dd57a16 | ||
|
|
0928e78bec | ||
|
|
047b39064d | ||
|
|
ffdb88d13c | ||
|
|
ec9dac0789 | ||
|
|
f622052c67 | ||
|
|
1446e87bba | ||
|
|
bc74b98cfd | ||
|
|
cb3310b44b | ||
|
|
2bf00eff3c | ||
|
|
94ff9f55d9 | ||
|
|
4342cdcc67 | ||
|
|
0c4019c67d | ||
|
|
d03fa632e7 | ||
|
|
7d07a4f00d | ||
|
|
f78bbea4b0 | ||
|
|
8c6b3fd5dc | ||
|
|
dbc90548e2 | ||
|
|
95ff83027c | ||
|
|
67a5514654 | ||
|
|
4df13cbe70 | ||
|
|
a5ab338147 | ||
|
|
9022cb103a | ||
|
|
4985aef14a | ||
|
|
0e3ee37fd8 | ||
|
|
c08941aed3 | ||
|
|
1fbfa0e590 | ||
|
|
7fd99343bd | ||
|
|
23164bcf2d | ||
|
|
a7a654b72a | ||
|
|
c02905e954 | ||
|
|
b6dc252bc9 | ||
|
|
6af3f58e89 | ||
|
|
e686ddad57 | ||
|
|
fb70237a12 | ||
|
|
101dc38910 | ||
|
|
6696881ee5 | ||
|
|
329ac9c3b0 | ||
|
|
93874d16bb | ||
|
|
5d139c8f4a | ||
|
|
2838016244 | ||
|
|
e2a5d43e76 | ||
|
|
c2d9023418 | ||
|
|
cf54ded78c | ||
|
|
a29838e2c7 | ||
|
|
b327997021 | ||
|
|
efeb42e0fe | ||
|
|
0428017cac | ||
|
|
c899a71fd8 | ||
|
|
93ff2fc3e0 | ||
|
|
53a510f23d | ||
|
|
a36aa28de0 | ||
|
|
8dd94ed744 | ||
|
|
7ba1c5b7dd | ||
|
|
507f415710 | ||
|
|
7c3fc4bd50 | ||
|
|
59e42f31b5 | ||
|
|
0bd5311b6b | ||
|
|
cccefb0775 | ||
|
|
2bf38e0f63 | ||
|
|
ae8b06d66d | ||
|
|
19633afde1 | ||
|
|
9f0f6a75c9 | ||
|
|
f6060451c0 | ||
|
|
083fe3e364 | ||
|
|
6c0835227d | ||
|
|
525322b6f6 | ||
|
|
75a96389fe | ||
|
|
8de89cb579 | ||
|
|
e900c5a1c2 | ||
|
|
2ccb378672 | ||
|
|
f3b50bd0a3 | ||
|
|
9bd4c51d65 | ||
|
|
0542757afa | ||
|
|
bb71955576 | ||
|
|
f7a3b20087 | ||
|
|
531ca100ac | ||
|
|
fde71116ff | ||
|
|
08750087b1 | ||
|
|
9d45071a36 | ||
|
|
482294b8b7 | ||
|
|
c00c18e878 | ||
|
|
81b877bb7f | ||
|
|
f2efd8355e | ||
|
|
cbd8f44b50 | ||
|
|
00bb95431c | ||
|
|
3db912a11a | ||
|
|
928be7ccd1 | ||
|
|
96c637618b | ||
|
|
8aa5ab2ded | ||
|
|
eac6529a77 | ||
|
|
e99a0ab075 | ||
|
|
12efe0be17 | ||
|
|
a75be83d04 | ||
|
|
a8a55b9e24 | ||
|
|
ed57e2d194 | ||
|
|
45ac7a9965 | ||
|
|
c5b623f1f6 | ||
|
|
89c3549e07 | ||
|
|
6c80c08a83 | ||
|
|
f3e90adea3 | ||
|
|
e80032f668 | ||
|
|
9ccbabadf2 | ||
|
|
0c7b33aa70 | ||
|
|
0a8e11a28d | ||
|
|
a000ba0905 | ||
|
|
3817b2d3f7 | ||
|
|
48e4aabd4b | ||
|
|
42fe62cb41 | ||
|
|
5c142273aa | ||
|
|
6e7160c935 | ||
|
|
27209def4b | ||
|
|
9ea8300973 | ||
|
|
1b7a142162 | ||
|
|
6d5d565aa7 | ||
|
|
863718020b | ||
|
|
22d7205271 | ||
|
|
676bdc076f | ||
|
|
14153a2f03 | ||
|
|
ca5913c4e6 | ||
|
|
8a4bb44354 | ||
|
|
aeb3b4c64b | ||
|
|
2d3d589fe6 | ||
|
|
ed43c69a1d | ||
|
|
96b10b9e75 | ||
|
|
155e3d9ea7 | ||
|
|
a68266ca3c | ||
|
|
9e399c9f64 | ||
|
|
ab99ba2fe5 | ||
|
|
21f6b63ce4 | ||
|
|
bb12c9a0e9 | ||
|
|
c2b03f5621 | ||
|
|
6b38359a90 | ||
|
|
71b9de8d21 | ||
|
|
7c8ba8d638 | ||
|
|
af855cb6c8 | ||
|
|
0a2453bdab | ||
|
|
b5e40c68d2 | ||
|
|
c95da96017 | ||
|
|
dfcd59481c | ||
|
|
d2a51f3aee | ||
|
|
78de0418e1 | ||
|
|
4c2c68d993 | ||
|
|
10ef054de6 | ||
|
|
9b3fd7b314 | ||
|
|
817bf8f548 | ||
|
|
82b98fdfe6 | ||
|
|
642be0f9df | ||
|
|
e2cf3d309a | ||
|
|
89688e02f3 | ||
|
|
56fb6b319b | ||
|
|
39d064c30c | ||
|
|
dbb8050dc5 | ||
|
|
4960d4659f | ||
|
|
11d2a4671e | ||
|
|
35024b4797 | ||
|
|
d7f3240a82 | ||
|
|
71890ffb4c | ||
|
|
31e18c5965 | ||
|
|
0351f9116d | ||
|
|
51809468a9 | ||
|
|
9261df87e0 | ||
|
|
d735824d57 | ||
|
|
55d8920a82 | ||
|
|
81398549e2 | ||
|
|
0ad29ae5fa |
No files matched your search
@@ -0,0 +1,304 @@
|
|||||||
|
# 📋 Project Knowledge — Web Antrean (Sistem Antrian Rumah Sakit)
|
||||||
|
|
||||||
|
> Dokumen ini berisi knowledge base lengkap, aturan pengkodean, dan workflow wajib untuk project **web-antrean**.
|
||||||
|
> **WAJIB DIBACA** sebelum melakukan task apapun.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 ATURAN WAJIB (MANDATORY RULES)
|
||||||
|
|
||||||
|
### Rule 1: Selalu Buat Implementation Plan Sebelum Eksekusi
|
||||||
|
- **SEBELUM** menulis kode apapun, buat implementation plan di `docs/DEVPLAN.md` atau artifact
|
||||||
|
- Plan harus mencakup: file yang akan diubah, perubahan spesifik, dan alasan
|
||||||
|
- Tunggu persetujuan user sebelum eksekusi (kecuali fix minor/typo)
|
||||||
|
|
||||||
|
### Rule 2: Selalu Tambahkan Komentar di Setiap Fungsi
|
||||||
|
- Setiap **function**, **method**, **computed**, dan **watcher** HARUS punya komentar JSDoc/TSDoc
|
||||||
|
- Format minimum: `/** Deskripsi singkat fungsi ini */`
|
||||||
|
- Untuk fungsi kompleks, tambahkan `@param`, `@returns`, `@example`
|
||||||
|
- Komentar inline untuk logika yang tidak obvious
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ BENAR
|
||||||
|
/**
|
||||||
|
* Mengambil data pasien dari API berdasarkan ID loket
|
||||||
|
* @param {string} loketId - ID loket yang akan di-fetch
|
||||||
|
* @returns {Promise<QueuePatient[]>} Daftar pasien dalam antrian
|
||||||
|
*/
|
||||||
|
const fetchPatientsForLoket = async (loketId) => { ... }
|
||||||
|
|
||||||
|
// ❌ SALAH — tanpa komentar
|
||||||
|
const fetchPatientsForLoket = async (loketId) => { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 3: Selalu Update Dokumentasi Setelah Perubahan
|
||||||
|
- **DEVLOG.md**: Tambahkan entry untuk setiap perubahan signifikan
|
||||||
|
- **DEVPLAN.md**: Update status task yang sudah selesai
|
||||||
|
- **PRD.md**: Update jika ada perubahan requirements/API/arsitektur
|
||||||
|
- **AGENTS.md**: Update jika ada knowledge baru yang penting
|
||||||
|
|
||||||
|
### Rule 4: Selalu Baca AGENTS.md dan Skills Sebelum Eksekusi
|
||||||
|
- Baca file ini SEBELUM mulai task apapun
|
||||||
|
- Baca skill `web-antrean-coding-standards` untuk konvensi kode
|
||||||
|
- Jangan mengulang kesalahan yang sudah tercatat di bagian Gotchas
|
||||||
|
|
||||||
|
### Rule 5: Jangan Mengulang Kesalahan yang Sudah Diketahui
|
||||||
|
- Cek bagian **⚠️ Gotchas & Known Issues** sebelum debug
|
||||||
|
- Cek **DEVLOG.md** untuk masalah serupa yang pernah diselesaikan
|
||||||
|
- Jika menemukan masalah baru, catat di DEVLOG dan Gotchas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Arsitektur & Tech Stack
|
||||||
|
|
||||||
|
| Layer | Teknologi |
|
||||||
|
|---|---|
|
||||||
|
| **Framework** | Nuxt 3 (`^3.17.7`) dengan SSR |
|
||||||
|
| **UI Library** | Vuetify 3 (`^3.9.3`) + Material Design Icons (`@mdi/font`) |
|
||||||
|
| **State Management** | Pinia (`^3.0.3`) + `pinia-plugin-persistedstate` |
|
||||||
|
| **Styling** | SCSS (`sass-embedded`), design tokens di `assets/scss/` |
|
||||||
|
| **Auth** | Keycloak OAuth2 (custom server-side implementation, bukan nuxt-auth) |
|
||||||
|
| **Database (Server)** | SQLite via `better-sqlite3` — untuk konfigurasi device & user sync |
|
||||||
|
| **Charting** | Chart.js + vue-chartjs + nuxt-charts |
|
||||||
|
| **QR Code** | `html5-qrcode` (scanner) + `qrcode.vue` / `nuxt-qrcode` (generator) |
|
||||||
|
| **WebSocket** | Custom composable (`useWebSocket.ts`) ke backend Go |
|
||||||
|
| **Deployment** | Docker + docker-compose |
|
||||||
|
| **Testing** | Vitest (unit) + Cypress (E2E) |
|
||||||
|
| **Font** | Google Fonts (Inter 400-700) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Struktur Direktori
|
||||||
|
|
||||||
|
```
|
||||||
|
web-antrean/
|
||||||
|
├── app.vue # Entry point — store schema migration + loading overlay
|
||||||
|
├── nuxt.config.ts # Konfigurasi Nuxt, Vuetify, runtime config, proxy
|
||||||
|
├── .env # Environment variables (API URLs, auth, proxy)
|
||||||
|
├── docker-compose.yml # Docker deployment config
|
||||||
|
│
|
||||||
|
├── .agents/ # Agent rules & skills
|
||||||
|
│ ├── AGENTS.md # FILE INI — rules & knowledge base
|
||||||
|
│ └── skills/ # Coding standards & workflow skills
|
||||||
|
│
|
||||||
|
├── docs/ # Dokumentasi project
|
||||||
|
│ ├── DEVLOG.md # Development log (kronologis)
|
||||||
|
│ ├── DEVPLAN.md # Development plan (task breakdown)
|
||||||
|
│ └── PRD.md # Product Requirements Document
|
||||||
|
│
|
||||||
|
├── assets/scss/ # Design system
|
||||||
|
│ ├── _colors.scss # Color tokens (CSS variables)
|
||||||
|
│ ├── _variables.scss # Spacing, breakpoints, etc.
|
||||||
|
│ ├── _typography.scss # Font styles
|
||||||
|
│ └── main.scss # Global imports
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # Shared: PageHeader, Avatar, AppSnackbar, SelectionDialog
|
||||||
|
│ ├── layout/ # SideBar, ProfileMenu, ProfilePopup
|
||||||
|
│ ├── features/ # Feature-specific (antrean, master, monitoring, queue)
|
||||||
|
│ ├── AdminKlinik/ # Komponen admin klinik eksekutif
|
||||||
|
│ ├── GrandPaviliun/ # Komponen Grand Paviliun (Gp*)
|
||||||
|
│ ├── HakAkses/ # Komponen manajemen hak akses
|
||||||
|
│ ├── MonitorPasien/ # Komponen monitoring pasien
|
||||||
|
│ ├── checkin/ # Komponen check-in
|
||||||
|
│ ├── verification/ # Komponen verifikasi akun
|
||||||
|
│ └── preview/ # Komponen preview
|
||||||
|
│
|
||||||
|
├── composables/
|
||||||
|
│ ├── useAuth.ts # Auth state & session management
|
||||||
|
│ ├── useWebSocket.ts # WebSocket client dengan auto-reconnect
|
||||||
|
│ ├── useQueueAPI.ts # API wrapper untuk antrian (verificationApiBaseUrl)
|
||||||
|
│ ├── useVisitAPI.ts # API wrapper untuk visit (externalApiBaseUrl)
|
||||||
|
│ ├── useQueueSync.ts # Sinkronisasi antrian antar client
|
||||||
|
│ ├── useCheckIn.ts # Logika check-in pasien
|
||||||
|
│ ├── useCheckInHistory.ts # Riwayat check-in
|
||||||
|
│ ├── useClinicAPI.ts # API wrapper klinik
|
||||||
|
│ ├── useGrandPaviliun.ts # Logika Grand Paviliun
|
||||||
|
│ ├── useHakAkses.ts # Manajemen hak akses
|
||||||
|
│ ├── usePermissions.ts # Permission check helper
|
||||||
|
│ ├── useQRGenerator.ts # QR code generation
|
||||||
|
│ ├── useQRScanner.ts # QR code scanning (html5-qrcode)
|
||||||
|
│ ├── useThermalPrint.ts # Thermal printer (tiket antrian)
|
||||||
|
│ ├── useSnackbar.ts # Toast notification
|
||||||
|
│ └── useInfiniteScroll.ts # Infinite scroll pagination
|
||||||
|
│
|
||||||
|
├── layouts/
|
||||||
|
│ ├── default.vue # Main layout — SideBar + middleware auth + checkPageAccess
|
||||||
|
│ └── empty.vue # Empty layout (untuk halaman tanpa sidebar)
|
||||||
|
│
|
||||||
|
├── middleware/
|
||||||
|
│ ├── auth.ts # Auth check → redirect ke /LoginPage jika belum login
|
||||||
|
│ ├── guest.ts # Untuk halaman publik
|
||||||
|
│ ├── checkPageAccess.ts # Cek akses halaman berdasarkan hak akses
|
||||||
|
│ └── permissions.ts # Auto-sync permissions dari backend (DISABLED)
|
||||||
|
│
|
||||||
|
├── pages/ # Semua halaman (lihat detail di PRD.md section 8.1)
|
||||||
|
│ ├── AdminKlinikRuang/ # [kodeKlinik].vue (~111KB)
|
||||||
|
│ ├── AdminLoket/ # [id].vue (~58KB)
|
||||||
|
│ ├── CheckInPasien/ # checkIn.vue (~230KB, LARGEST)
|
||||||
|
│ ├── Anjungan/ # Display anjungan (kiosk)
|
||||||
|
│ ├── Setting/ # Master data & konfigurasi
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── server/
|
||||||
|
│ ├── api/auth/ # Auth endpoints (Keycloak OAuth flow)
|
||||||
|
│ ├── api/users/ # User management (SQLite-backed)
|
||||||
|
│ ├── api/config/ # Device configuration (SQLite-backed)
|
||||||
|
│ ├── api/hak-akses/ # Hak akses management
|
||||||
|
│ └── utils/ # configDb.ts, sessionStore.ts, userSync.ts
|
||||||
|
│
|
||||||
|
├── stores/ # Pinia stores (Single Source of Truth)
|
||||||
|
│ ├── clinicStore.js # Master data klinik (~1007 lines)
|
||||||
|
│ ├── queueStore.ts # Antrian pasien (~3302 lines, LARGEST)
|
||||||
|
│ ├── loketStore.js # Data loket (~549 lines)
|
||||||
|
│ ├── ruangStore.js # Data ruang klinik (~558 lines)
|
||||||
|
│ ├── masterStore.js # BACKWARD COMPAT LAYER (jangan tulis langsung!)
|
||||||
|
│ ├── penunjangStore.js # Data penunjang medis
|
||||||
|
│ ├── doctorStore.js # Data dokter (fetched from API)
|
||||||
|
│ └── ... # anjunganStore, screenStore, navItems1, dll
|
||||||
|
│
|
||||||
|
├── types/ # TypeScript interfaces & types
|
||||||
|
│ ├── auth.ts, queue.ts, checkin.ts, setting.ts
|
||||||
|
│
|
||||||
|
└── data/
|
||||||
|
└── users.db # SQLite database (user sync + device config)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 API Endpoints & External Services
|
||||||
|
|
||||||
|
### Environment Variables (`.env`)
|
||||||
|
|
||||||
|
| Variable | Deskripsi | Value (Juli 2026) |
|
||||||
|
|---|---|---|
|
||||||
|
| `ANTRIAN_API_URL` | API antrian utama (Go backend) | `http://10.10.150.131:8089/api/v1` |
|
||||||
|
| `VERIFICATION_API_BASE_URL` | Alias untuk ANTRIAN_API_URL | (same) |
|
||||||
|
| `VISIT_API_URL` | API visit/kunjungan pasien | `http://10.10.123.135:8084/api/v1` |
|
||||||
|
| `WS_API_URL` | WebSocket URL | `ws://10.10.123.135:8084/api/v1/ws` |
|
||||||
|
| `EXTERNAL_API_BASE_URL` | External API (JWT validation) | `http://10.10.123.135:8084` |
|
||||||
|
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.rssa.top/realms/sandbox` |
|
||||||
|
| `AUTH_ORIGIN` | Aplikasi origin URL | `http://10.10.150.175:3000` |
|
||||||
|
| `HOST` | Dev server host | `http://10.10.150.175:3000` |
|
||||||
|
|
||||||
|
### Runtime Config (`nuxt.config.ts`)
|
||||||
|
|
||||||
|
```
|
||||||
|
runtimeConfig.public.verificationApiBaseUrl → ANTRIAN_API_URL (antrian API)
|
||||||
|
runtimeConfig.public.externalApiBaseUrl → VISIT_API_URL (visit API)
|
||||||
|
runtimeConfig.public.wsBaseUrl → WS_API_URL (WebSocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
### External Backend APIs
|
||||||
|
|
||||||
|
1. **Antrian API** (`verificationApiBaseUrl`) — Go backend
|
||||||
|
- `GET /klinik/reguler` — Fetch daftar klinik reguler
|
||||||
|
- `GET /loket/ruang` — Fetch daftar ruangan per klinik
|
||||||
|
- `GET /loket/{id}` — Fetch pasien per loket
|
||||||
|
- `GET /dokter/{id}` — Fetch data dokter per klinik
|
||||||
|
- `POST /tiket/generate` — Generate tiket antrian
|
||||||
|
- `POST /tiket/checkin` — Check-in pasien
|
||||||
|
- `GET /permission` — Fetch permission/hak akses
|
||||||
|
|
||||||
|
2. **Visit API** (`externalApiBaseUrl`) — Visit/kunjungan
|
||||||
|
- `GET /visit?klinik_id={id}` — Fetch data pasien per klinik
|
||||||
|
- `POST /external/validate-token` — Validate JWT token
|
||||||
|
|
||||||
|
3. **WebSocket** (`wsBaseUrl`)
|
||||||
|
- Real-time queue updates, connected via `useWebSocket.ts`
|
||||||
|
|
||||||
|
> **PENTING**: Semua kode menggunakan `config.public.verificationApiBaseUrl` dengan fallback hardcoded. Saat ganti IP, ubah di `.env` lalu **restart dev server**. Nuxt hanya membaca `.env` saat startup!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Store Architecture
|
||||||
|
|
||||||
|
### Hirarki Store
|
||||||
|
|
||||||
|
```
|
||||||
|
masterStore (BACKWARD COMPAT LAYER — JANGAN TULIS LANGSUNG)
|
||||||
|
├── clinicStore → Single source of truth untuk data KLINIK
|
||||||
|
├── loketStore → Data LOKET (counter antrian)
|
||||||
|
├── ruangStore → Data RUANG per klinik
|
||||||
|
└── penunjangStore → Data PENUNJANG medis
|
||||||
|
|
||||||
|
queueStore → Data ANTRIAN PASIEN (terbesar, 136KB)
|
||||||
|
doctorStore → Data DOKTER (fetched per klinik)
|
||||||
|
anjunganStore → Config ANJUNGAN (kiosk)
|
||||||
|
screenStore → Config SCREEN display
|
||||||
|
antreanMasukScreenStore → Config screen antrian masuk
|
||||||
|
navItems1 → Navigation items + hak akses filter
|
||||||
|
permissionStore → Permission data
|
||||||
|
verificationStore → Verifikasi akun
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store Schema Migration
|
||||||
|
- `app.vue` manages `STORE_SCHEMA_VERSION` (currently `3`)
|
||||||
|
- Saat versi tidak cocok, semua localStorage Pinia di-clear
|
||||||
|
- **Naikkan versi** saat ada perubahan struktur state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Authentication Flow
|
||||||
|
|
||||||
|
1. **Login**: User klik login → `keycloak-login.ts` → redirect ke Keycloak
|
||||||
|
2. **Callback**: Keycloak redirect back → `keycloak-callback.get.ts` → exchange code for token → create session cookie
|
||||||
|
3. **Session**: `session.get.ts` validates cookie → returns user data
|
||||||
|
4. **Middleware**: `auth.ts` checks session on every protected route
|
||||||
|
5. **User Sync**: `userSync.ts` syncs Keycloak users to local SQLite DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Gotchas & Known Issues (HARUS DIBACA SEBELUM DEBUG)
|
||||||
|
|
||||||
|
1. **ENV reload**: Nuxt hanya membaca `.env` saat startup. Setelah ubah `.env`, HARUS restart dev server (`Ctrl+C` → `npm run dev`).
|
||||||
|
|
||||||
|
2. **Hardcoded fallback IPs**: Banyak file masih punya fallback IP lama. Saat ganti IP, ubah `.env` + restart server. JANGAN cuma ubah satu file.
|
||||||
|
|
||||||
|
3. **`totalQuota === 0` filter di ruangStore**: Klinik dengan `totalQuota: 0` diskip dari Admin Klinik Ruang. Ini menyembunyikan klinik yang valid tapi belum dikonfigurasi kuotanya. Trade-off yang disengaja.
|
||||||
|
|
||||||
|
4. **masterStore adalah proxy**: Jangan langsung tulis data ke masterStore. Selalu gunakan store yang sesuai (clinicStore, loketStore, ruangStore, penunjangStore).
|
||||||
|
|
||||||
|
5. **File besar** (hati-hati saat edit):
|
||||||
|
- `CheckInPasien/checkIn.vue` (~230KB)
|
||||||
|
- `queueStore.ts` (~136KB)
|
||||||
|
- `AdminKlinikRuang/[kodeKlinik].vue` (~111KB)
|
||||||
|
|
||||||
|
6. **SQLite DB path**: `data/users.db` — digunakan untuk konfigurasi device & user sync. Di Docker, di-mount sebagai volume.
|
||||||
|
|
||||||
|
7. **WebSocket auto-upgrade**: `useWebSocket.ts` otomatis upgrade `ws://` ke `wss://` jika halaman diakses via HTTPS.
|
||||||
|
|
||||||
|
8. **Pinia persist `paths` type mismatch**: Gunakan `// @ts-ignore` untuk properti `paths` karena kompatibel runtime tapi melanggar validasi tipe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Key File Quick Reference
|
||||||
|
|
||||||
|
| Kebutuhan | File |
|
||||||
|
|---|---|
|
||||||
|
| Ganti API URL | `.env` → restart dev server |
|
||||||
|
| Auth flow | `server/api/auth/` |
|
||||||
|
| Konfigurasi Nuxt | `nuxt.config.ts` |
|
||||||
|
| Data klinik | `stores/clinicStore.js` |
|
||||||
|
| Data antrian | `stores/queueStore.ts` |
|
||||||
|
| Data ruang | `stores/ruangStore.js` |
|
||||||
|
| Data loket | `stores/loketStore.js` |
|
||||||
|
| Sidebar/Navigation | `stores/navItems1.ts` + `components/layout/SideBar.vue` |
|
||||||
|
| Design tokens | `assets/scss/_colors.scss`, `_variables.scss` |
|
||||||
|
| Database schema | `server/utils/configDb.ts` |
|
||||||
|
| User management | `server/api/users/` + `server/utils/userSync.ts` |
|
||||||
|
| WebSocket | `composables/useWebSocket.ts` |
|
||||||
|
| Thermal print | `composables/useThermalPrint.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server
|
||||||
|
npm run dev:https # Start with HTTPS + host 0.0.0.0
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
npm run test # Run Vitest
|
||||||
|
docker compose up -d # Deploy production
|
||||||
|
```
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
name: web-antrean-coding-standards
|
||||||
|
description: Aturan dasar pengkodean dan konvensi untuk project web-antrean berbasis Nuxt 3, Vue 3, TypeScript, Pinia, dan Vuetify.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📜 Web Antrean Coding Standards
|
||||||
|
|
||||||
|
Skill ini berisi standar pengkodean wajib yang harus diikuti saat memodifikasi atau membuat kode baru di project `web-antrean`.
|
||||||
|
|
||||||
|
## 1. Komentar & Dokumentasi (WAJIB)
|
||||||
|
- **Tiap Fungsi/Method/Computed/Watcher HARUS dikomentari.**
|
||||||
|
- Gunakan JSDoc (`/** ... */`) untuk mendeskripsikan tujuan fungsi.
|
||||||
|
- Jika fungsi kompleks atau memiliki parameter spesifik, gunakan tag `@param`, `@returns`.
|
||||||
|
- Berikan komentar singkat (`//`) di dalam body fungsi jika ada logika yang tidak langsung jelas (non-obvious).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ CONTOH BENAR
|
||||||
|
/**
|
||||||
|
* Menghitung total antrean aktif untuk loket tertentu.
|
||||||
|
* @param {string} loketId - ID unik dari loket.
|
||||||
|
* @returns {number} Jumlah antrean berstatus 'menunggu'.
|
||||||
|
*/
|
||||||
|
const countActiveQueue = (loketId: string): number => {
|
||||||
|
// Hanya hitung pasien yang belum dipanggil atau di-skip
|
||||||
|
return allPatients.value.filter(p => p.loketId === loketId && p.status === 'menunggu').length;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Struktur Komponen (Vue 3 / Nuxt 3)
|
||||||
|
- Gunakan `<script setup lang="ts">`. Sebisa mungkin gunakan TypeScript.
|
||||||
|
- **Urutan blok dalam Vue SFC (Single File Component):**
|
||||||
|
1. `<template>`
|
||||||
|
2. `<script setup lang="ts">`
|
||||||
|
3. `<style scoped>` (Lebih disukai `<style scoped lang="scss">`)
|
||||||
|
- **Organisasi dalam `<script setup>`:**
|
||||||
|
1. `imports` (Vue, composables, stores, types)
|
||||||
|
2. `defineProps`, `defineEmits`, `defineExpose`
|
||||||
|
3. State variables (`ref`, `reactive`)
|
||||||
|
4. Computed properties (`computed`)
|
||||||
|
5. Watchers (`watch`, `watchEffect`)
|
||||||
|
6. Functions / Methods
|
||||||
|
7. Lifecycle hooks (`onMounted`, `onUnmounted`)
|
||||||
|
|
||||||
|
## 3. Composables (`composables/`)
|
||||||
|
- Nama file dan nama fungsi harus diawali dengan `use` (contoh: `useQueueSync.ts`, `export const useQueueSync = () => {}`).
|
||||||
|
- Fokuskan composable pada satu fitur spesifik (contoh: `useThermalPrint` khusus print, `useWebSocket` khusus WS).
|
||||||
|
- Jika mengembalikan state reaktif, kembalikan objek berisi `ref` atau `computed`.
|
||||||
|
|
||||||
|
## 4. Pinia Stores (`stores/`)
|
||||||
|
- Gunakan format Setup Store (seperti composable) alih-alih Option Store.
|
||||||
|
- Berikan tipe eksplisit (TypeScript) untuk state array/object yang kompleks.
|
||||||
|
- Jangan melakukan mutasi data store lain secara langsung, gunakan actions.
|
||||||
|
- **PENTING:** Jika ada store baru, daftarkan di `PINIA_STORE_KEYS` di `app.vue` jika perlu di-persist dan bersihkan localStorage jika `STORE_SCHEMA_VERSION` naik.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ CONTOH SETUP STORE
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { QueuePatient } from '~/types/queue';
|
||||||
|
|
||||||
|
export const useMyStore = defineStore('myStore', () => {
|
||||||
|
// State
|
||||||
|
const patients = ref<QueuePatient[]>([]);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const activePatients = computed(() => patients.value.filter(p => p.status === 'menunggu'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action untuk menambah pasien
|
||||||
|
* @param {QueuePatient} newPatient
|
||||||
|
*/
|
||||||
|
const addPatient = (newPatient: QueuePatient) => {
|
||||||
|
patients.value.push(newPatient);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { patients, activePatients, addPatient };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Integrasi API & Error Handling
|
||||||
|
- Gunakan `useRuntimeConfig().public` untuk base URL (contoh: `verificationApiBaseUrl`).
|
||||||
|
- **JANGAN** hardcode IP address (seperti `10.10.123.140`) di dalam kode komponen atau composable. Selalu ambil dari config.
|
||||||
|
- Gunakan composable wrapper atau `useFetch`/`$fetch` dari Nuxt.
|
||||||
|
- Harus ada blok `try...catch` untuk setiap pemanggilan API, dengan logging error yang jelas (minimal `console.error`).
|
||||||
|
|
||||||
|
## 6. Styling
|
||||||
|
- Gunakan class Vuetify sebanyak mungkin (`d-flex`, `mt-4`, `text-h5`, `text-primary`).
|
||||||
|
- Jika butuh custom style, gunakan `<style scoped lang="scss">` dan manfaatkan variabel dari `assets/scss/_colors.scss` atau `_variables.scss` jika memungkinkan.
|
||||||
|
|
||||||
|
## 7. Naming Conventions
|
||||||
|
- **Komponen:** PascalCase (contoh: `PatientCard.vue`, `AdminKlinik.vue`).
|
||||||
|
- **File/Folder (selain komponen):** camelCase atau kebab-case tergantung standar folder (Nuxt standard: pages biasanya kebab-case atau PascalCase jika spesifik view).
|
||||||
|
- **Fungsi/Variabel:** camelCase (contoh: `fetchPatients`, `isActive`).
|
||||||
|
- **Tipe Data (Types/Interfaces):** PascalCase (contoh: `QueuePatient`).
|
||||||
|
|
||||||
|
## 8. Aturan Refactoring (Refactoring Rules)
|
||||||
|
Saat melakukan refactoring (terutama mengkonversi `.js` lama ke `.ts` atau memecah file besar), ikuti aturan wajib berikut:
|
||||||
|
- **Incremental Refactoring (Bertahap):** Jangan merombak seluruh file raksasa (seperti `queueStore` atau `checkIn.vue`) sekaligus. Pisahkan sebagian logika (contoh: pisahkan API call ke `useQueueAPI`, WS ke `useQueueSync`) secara bertahap untuk meminimalisasi *blast radius* (kerusakan beruntun).
|
||||||
|
- **Isolasi Logika (Separation of Concerns):** Komponen `.vue` hanya boleh berisi logika UI (presentational). Logika bisnis berat harus dipindah ke `composables/` atau `stores/`.
|
||||||
|
- **Backward Compatibility (Kompabilitas Mundur):** Jika mengubah struktur *store* yang banyak dipakai (contoh: `masterStore`), jangan langsung menghapus properti lama. Buat *proxy/delegate* ke store baru sampai semua komponen selesai di-update.
|
||||||
|
- **No Implicit `any`:** Saat memigrasi `.js` ke `.ts`, WAJIB mendefinisikan tipe data/interface yang jelas di direktori `types/`. Sebisa mungkin hindari penggunaan tipe `any`.
|
||||||
|
- **Pertahankan Business Logic:** Refactoring HANYA mengubah struktur/kebersihan kode, **BUKAN** mengubah alur bisnis kecuali diminta secara spesifik. Pastikan fitur safety seperti *polling fallback 30s* atau *auto-reconnect WS* tidak terhapus tanpa sengaja.
|
||||||
|
- **Dokumentasikan Perubahan:** Jika refactor mengubah alur arsitektur secara signifikan, selalu catat di `docs/DEVLOG.md` dan perbarui `AGENTS.md`.
|
||||||
|
|
||||||
|
## 9. Reusability & Prinsip DRY (Don't Repeat Yourself)
|
||||||
|
- Jika suatu fungsi, blok UI, atau logika API dipakai di lebih dari dua tempat, **wajib** diekstrak menjadi `Composable` (untuk logika), `Component` (untuk UI), atau file utility di `utils/`.
|
||||||
|
- Jangan melakukan copy-paste kode panjang antar halaman.
|
||||||
|
|
||||||
|
## 10. Penggunaan State (Lokal vs Global)
|
||||||
|
- **Lokal (`ref` / `reactive`):** Gunakan untuk state yang hanya hidup di satu komponen (contoh: status loading tombol, form input sementara, toggle modal).
|
||||||
|
- **Global (Pinia):** Gunakan HANYA untuk state yang perlu dibagikan lintas halaman/komponen (contoh: data pasien antrean berjalan, data user login, master data klinik).
|
||||||
|
|
||||||
|
## 11. Props & Emits (Komunikasi Antar Komponen)
|
||||||
|
- Definisikan `props` secara eksplisit menggunakan `defineProps<{ ... }>()` dengan tipe data yang ketat.
|
||||||
|
- Gunakan `defineEmits<{ ... }>()` untuk event child-to-parent. Hindari memanipulasi prop secara langsung dari child komponen (mutate prop mutation).
|
||||||
|
|
||||||
|
## 12. Manajemen Siklus Hidup (Lifecycle & Cleanup)
|
||||||
|
- Jika menginisiasi *event listener* global (`window.addEventListener`), *timer* (`setInterval`), atau *WebSocket subscription* di `onMounted`, Anda **WAJIB** membersihkannya di `onUnmounted`.
|
||||||
|
- Kegagalan melakukan *cleanup* akan menyebabkan kebocoran memori (memory leak) terutama di aplikasi SPA (Single Page Application).
|
||||||
|
|
||||||
|
## 13. Navigasi & Routing (Nuxt 3)
|
||||||
|
- Gunakan `<NuxtLink>` untuk navigasi internal di template. JANGAN menggunakan tag `<a>` biasa karena akan memicu *full page reload*.
|
||||||
|
- Untuk navigasi terprogram (programmatic) di dalam script, gunakan `navigateTo()` atau `useRouter().push()`.
|
||||||
|
|
||||||
|
## 14. Keamanan Dasar (Security Practices)
|
||||||
|
- **JANGAN PERNAH** meletakkan token JWT, password, atau secret key secara hardcode di sisi klien.
|
||||||
|
- Baca variabel sensitif melalui `useRuntimeConfig()` yang disuplai dari `.env`.
|
||||||
|
- Selalu percayakan validasi otorisasi di sisi *server* (middleware/Nitro), jangan hanya menyembunyikan tombol di UI.
|
||||||
|
|
||||||
|
## 15. Penanganan Feedback UI (Error & Success Boundaries)
|
||||||
|
- Jangan biarkan error API tertelan diam-diam (`swallowed error`).
|
||||||
|
- Selalu tampilkan umpan balik ke pengguna jika terjadi kegagalan (misalnya menggunakan komponen `AppSnackbar` yang sudah ada).
|
||||||
|
- Berikan indikator visual (spinner/loading state) saat memanggil API.
|
||||||
|
|
||||||
|
## 16. Magic Numbers & Magic Strings
|
||||||
|
- Hindari hardcode angka atau string spesifik yang memiliki arti logika bisnis di tengah komponen (contoh: `if (status === 3)` atau `if (role === 'superadmin')`).
|
||||||
|
- Pindahkan ke file konstan di `constants/` atau jadikan `Enum` di direktori `types/`.
|
||||||
|
|
||||||
|
## 17. Penggunaan Proxy API (Bypass CORS)
|
||||||
|
- Untuk endpoint eksternal yang terhalang CORS (seperti API Visit atau API Antrian asli), panggil selalu via *Proxy Route* bawaan Nuxt Nitro (contoh: panggil `/visit-api/...` atau `/klinik-api/...`).
|
||||||
|
- Jangan mencoba menebak atau merubah setting header CORS di sisi *client* Vue.
|
||||||
|
|
||||||
|
## 18. Konsistensi Design System
|
||||||
|
- DILARANG melakukan *hardcode* warna HEX/RGB di tag `<style>` (contoh: `color: #ff0000;`).
|
||||||
|
- Selalu gunakan SCSS variables dari `assets/scss/_colors.scss` (contoh: `color: var(--color-primary);`) atau gunakan *utility classes* bawaan Vuetify.
|
||||||
|
|
||||||
|
## 19. Optimasi Performa Vue (v-memo, v-once, Lazy)
|
||||||
|
- Untuk *list rendering* yang sangat panjang (ratusan pasien), pertimbangkan penggunaan pagination, infinite scroll, atau virtual scrolling.
|
||||||
|
- Gunakan `LazyComponent` (prefix `Lazy` di Nuxt) untuk modal atau komponen berat yang tidak langsung muncul di layar.
|
||||||
|
|
||||||
|
## 20. Penanganan Koneksi Real-time (WebSocket)
|
||||||
|
- Pastikan ada mekanisme **Auto-Reconnect** dengan *backoff delay*.
|
||||||
|
- Selalu miliki **Polling Fallback**. Jika WS mati total, sistem harus fallback mengambil data via HTTP `setInterval` setiap 30 detik agar layar antrean tidak *stuck*.
|
||||||
|
- Cegah duplikasi data pesan (implementasi *deduplication logic* berdasarkan ID unik pesan).
|
||||||
|
|
||||||
|
## 21. Isolasi State per Entitas (Loket / Ruang)
|
||||||
|
- Jangan gunakan variabel state tunggal untuk sesuatu yang berjalan paralel.
|
||||||
|
- Contoh buruk: `currentPatient` (berisiko loket A tertukar pasien dengan loket B).
|
||||||
|
- Contoh benar: `currentPatientPerLoket: Record<string, Patient>` (diakses dengan ID loket).
|
||||||
|
|
||||||
|
## 22. Format Koding & Tanda Baca
|
||||||
|
- Ikuti standar *formatting* yang ada: indentasi 2 spasi, gunakan *single quotes* (`'`) untuk string JS/TS, dan selalu gunakan tanda titik koma (`;`) di akhir *statement* script.
|
||||||
|
|
||||||
|
## 23. Standar Pengujian (Testing Readiness)
|
||||||
|
- Setiap logika kalkulasi yang rumit (contoh: perhitungan estimasi waktu tunggu, filter spesialisasi) harus ditulis murni (pure function) agar mudah di-unit test nantinya (Vitest). Jangan mencampur logika ini dengan DOM manipulation.
|
||||||
|
|
||||||
|
## 24. Git Workflow & Commit Messages
|
||||||
|
- Tulis pesan *commit* yang jelas dan bermakna mengikuti pola *Conventional Commits*:
|
||||||
|
- `feat: ...` (fitur baru)
|
||||||
|
- `fix: ...` (perbaikan bug)
|
||||||
|
- `refactor: ...` (perubahan struktur tanpa ubah fitur)
|
||||||
|
- `docs: ...` (pembaruan dokumentasi)
|
||||||
|
- Satu *commit* sebaiknya fokus pada satu perubahan logika/fitur saja.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# IDEs and OS files
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# # .env
|
||||||
|
|
||||||
|
# # A random string used to hash tokens, sign cookies, and generate cryptographic keys.
|
||||||
|
# # You can generate one here: https://generate-secret.vercel.app/32
|
||||||
|
# NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
|
||||||
|
|
||||||
|
# # The base URL of your application
|
||||||
|
# AUTH_ORIGIN="http://localhost:3000"
|
||||||
|
|
||||||
|
# # Keycloak Credentials
|
||||||
|
# # Get these from your Keycloak client configuration
|
||||||
|
# KEYCLOAK_CLIENT_ID="nuxt-app"
|
||||||
|
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
|
||||||
|
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
|
||||||
|
|
||||||
|
|
||||||
|
# .env
|
||||||
|
|
||||||
|
# A random string used to hash tokens, sign cookies, and generate cryptographic keys.
|
||||||
|
# You can generate one here: https://generate-secret.vercel.app/32
|
||||||
|
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
|
||||||
|
|
||||||
|
# The base URL of your application
|
||||||
|
|
||||||
|
|
||||||
|
# Keycloak Credentials
|
||||||
|
# Get these from your Keycloak client configuration
|
||||||
|
# KEYCLOAK_CLIENT_ID="nuxt-app"
|
||||||
|
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
|
||||||
|
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
|
||||||
|
|
||||||
|
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||||
|
KEYCLOAK_CLIENT_SECRET="FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg"
|
||||||
|
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||||
|
SESSION_DURATION_HOURS=1
|
||||||
|
OAUTH_STATE_DURATION_MINUTES=10
|
||||||
|
|
||||||
|
#AUTH ORIGIN UNTUK ACCESS APLIKASI login dari KEYCLOAK
|
||||||
|
# AUTH_ORIGIN="http://10.10.150.175:3000"
|
||||||
|
# AUTH_ORIGIN="0.0.0.0"
|
||||||
|
# AUTH_ORIGIN="http://localhost:3000"
|
||||||
|
AUTH_ORIGIN="https://antrean.dev.rssa.id"
|
||||||
|
# AUTH_ORIGIN="https://antrean.rssa.id"
|
||||||
|
|
||||||
|
#HOST UNTUK ACCESS APLIKASI
|
||||||
|
# HOST="http://localhost:3000"
|
||||||
|
# HOST="http://10.10.150.175:3000"
|
||||||
|
# HOST="https://antrean.rssa.id"
|
||||||
|
# HOST="https://antrean.dev.rssa.id"
|
||||||
|
HOST="0.0.0.0"
|
||||||
|
# HOST=
|
||||||
|
|
||||||
|
# Custom Keycloak logout endpoint UBAH SESUAI REALM DAN DOMAIN KEYCLOAK ANDA
|
||||||
|
KEYCLOAK_LOGOUT_URI="https://auth.rssa.top/realms/sandbox/protocol/openid-connect/logout"
|
||||||
|
|
||||||
|
# Pilih salah satu dari dua opsi di bawah ini sesuai kebutuhan Anda:
|
||||||
|
# digunakan untuk redirect setelah logout dari KEYCLOAK
|
||||||
|
# Untuk redirect tanpa query parameter:
|
||||||
|
# POST_LOGOUT_REDIRECT_URI="http://10.10.150.175:3000/LoginPage"
|
||||||
|
# POST_LOGOUT_REDIRECT_URI="http://localhost:3000/LoginPage"
|
||||||
|
POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage"
|
||||||
|
# POST_LOGOUT_REDIRECT_URI=
|
||||||
|
|
||||||
|
# ATAU untuk redirect dengan query parameter:
|
||||||
|
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
|
||||||
|
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
|
||||||
|
|
||||||
|
|
||||||
|
# ATAU untuk redirect dengan query parameter:
|
||||||
|
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
|
||||||
|
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
|
||||||
|
|
||||||
|
# External API Configuration
|
||||||
|
# API eksternal untuk validasi JWT token
|
||||||
|
EXTERNAL_API_BASE_URL="http://10.10.150.100:8084"
|
||||||
|
EXTERNAL_API_TIMEOUT=10000
|
||||||
|
|
||||||
|
# API untuk ekstrak expertise dan verifikasi akun
|
||||||
|
# Digunakan di pages/verifikasiAkun/VerifikasiAkun.vue
|
||||||
|
EKSTRAK_EXPERTISE_URL="http://10.10.123.218/ekstrakexpertise"
|
||||||
|
VERIFICATION_API_BASE_URL="http://10.10.123.140:8089/api/v1"
|
||||||
|
|
||||||
|
# Simplified Modular API URLs (New Config)
|
||||||
|
ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1"
|
||||||
|
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
|
||||||
|
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
|
||||||
|
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
@@ -22,3 +28,6 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
REFACTORING_STATUS.md
|
||||||
|
CHECKIN_DOCUMENTATION.md
|
||||||
|
USERLOGIN_DOCUMENTATION.md
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# Cara Mengakses Database better-sqlite3
|
||||||
|
|
||||||
|
## 📍 Lokasi Database
|
||||||
|
- **Path relatif**: `data/users.db`
|
||||||
|
- **Path lengkap**: `E:\PROJECT\ddddd\Web-Antrean - Copy (2)\data\users.db`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Metode 1: Menggunakan Node.js Script (Recommended)
|
||||||
|
|
||||||
|
Karena project ini sudah menggunakan `better-sqlite3`, cara termudah adalah membuat script Node.js:
|
||||||
|
|
||||||
|
### Contoh Script untuk Query Database
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// scripts/query-db.js
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'data', 'users.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Contoh: Get all users
|
||||||
|
const users = db.prepare('SELECT * FROM users').all();
|
||||||
|
console.log('All users:', users);
|
||||||
|
|
||||||
|
// Contoh: Get user by ID
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('user-id-here');
|
||||||
|
console.log('User:', user);
|
||||||
|
|
||||||
|
// Contoh: Count users
|
||||||
|
const count = db.prepare('SELECT COUNT(*) as total FROM users').get();
|
||||||
|
console.log('Total users:', count.total);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jalankan dengan:**
|
||||||
|
```bash
|
||||||
|
node scripts/query-db.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Metode 2: Menggunakan SQLite CLI
|
||||||
|
|
||||||
|
### Install SQLite CLI (jika belum ada)
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
1. Download dari: https://www.sqlite.org/download.html
|
||||||
|
2. Atau install via Chocolatey: `choco install sqlite`
|
||||||
|
3. Atau install via Scoop: `scoop install sqlite`
|
||||||
|
|
||||||
|
**Atau gunakan npx (tidak perlu install):**
|
||||||
|
```bash
|
||||||
|
npx sqlite3 data/users.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Perintah SQLite CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Buka database
|
||||||
|
sqlite3 data/users.db
|
||||||
|
|
||||||
|
# Atau dengan npx (jika SQLite tidak terinstall)
|
||||||
|
npx sqlite3 data/users.db
|
||||||
|
|
||||||
|
# Di dalam SQLite shell:
|
||||||
|
.tables # Lihat semua tabel
|
||||||
|
.schema users # Lihat schema tabel users
|
||||||
|
SELECT * FROM users; # Lihat semua data
|
||||||
|
SELECT * FROM users LIMIT 10; # Lihat 10 baris pertama
|
||||||
|
.mode column # Format output sebagai kolom
|
||||||
|
.headers on # Tampilkan header kolom
|
||||||
|
.quit # Keluar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Metode 3: Menggunakan GUI Tools
|
||||||
|
|
||||||
|
### 1. **DB Browser for SQLite** (Gratis, Recommended)
|
||||||
|
- Download: https://sqlitebrowser.org/
|
||||||
|
- Buka file: `data/users.db`
|
||||||
|
- Bisa edit, query, dan export data dengan mudah
|
||||||
|
|
||||||
|
### 2. **SQLiteStudio** (Gratis)
|
||||||
|
- Download: https://sqlitestudio.pl/
|
||||||
|
- Cross-platform, open source
|
||||||
|
|
||||||
|
### 3. **DBeaver** (Gratis)
|
||||||
|
- Download: https://dbeaver.io/
|
||||||
|
- Universal database tool, support banyak database termasuk SQLite
|
||||||
|
|
||||||
|
### 4. **VS Code Extension**
|
||||||
|
- Install extension: **SQLite Viewer** atau **SQLite**
|
||||||
|
- Buka file `data/users.db` langsung di VS Code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Metode 4: Menggunakan API Endpoints yang Sudah Ada
|
||||||
|
|
||||||
|
Project ini sudah punya API endpoints untuk mengakses data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all users
|
||||||
|
GET /api/users/list
|
||||||
|
|
||||||
|
# Get current user
|
||||||
|
GET /api/users/current
|
||||||
|
|
||||||
|
# Get user by ID
|
||||||
|
GET /api/users/[id]
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
POST /api/users/create
|
||||||
|
|
||||||
|
# Update user
|
||||||
|
PATCH /api/users/[id]
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
DELETE /api/users/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Contoh Query Berguna
|
||||||
|
|
||||||
|
### Melihat semua users:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Melihat users dengan format tanggal yang readable:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
namaLengkap,
|
||||||
|
namaUser,
|
||||||
|
email,
|
||||||
|
datetime(lastLogin, 'unixepoch') as lastLoginFormatted,
|
||||||
|
datetime(createdAt, 'unixepoch') as createdAtFormatted
|
||||||
|
FROM users;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mencari user berdasarkan nama:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users WHERE namaLengkap LIKE '%nama%';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mencari user berdasarkan username:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users WHERE namaUser = 'username';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Melihat users yang belum login:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users WHERE lastLogin IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Melihat users yang baru dibuat:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users ORDER BY createdAt DESC LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export data ke CSV:
|
||||||
|
```sql
|
||||||
|
.mode csv
|
||||||
|
.headers on
|
||||||
|
.output users_export.csv
|
||||||
|
SELECT * FROM users;
|
||||||
|
.output stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Catatan Penting
|
||||||
|
|
||||||
|
1. **Backup sebelum edit manual**: Selalu backup database sebelum melakukan perubahan manual
|
||||||
|
2. **Tutup koneksi**: Pastikan aplikasi tidak sedang menggunakan database saat mengedit manual
|
||||||
|
3. **Format timestamps**: `lastLogin`, `createdAt`, `updatedAt` disimpan sebagai Unix timestamp (seconds)
|
||||||
|
4. **JSON fields**: `roles`, `realmRoles`, `accountRoles`, `resourceRoles`, `groups` disimpan sebagai JSON string
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Quick Access Script
|
||||||
|
|
||||||
|
Buat file `scripts/db-access.js` untuk akses cepat:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
const dbPath = path.join(process.cwd(), 'data', 'users.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📊 Database Access Tool');
|
||||||
|
console.log('Database path:', dbPath);
|
||||||
|
console.log('\nAvailable commands:');
|
||||||
|
console.log(' list - List all users');
|
||||||
|
console.log(' count - Count total users');
|
||||||
|
console.log(' schema - Show table schema');
|
||||||
|
console.log(' query <SQL> - Run custom SQL query');
|
||||||
|
console.log(' exit - Exit\n');
|
||||||
|
|
||||||
|
function prompt() {
|
||||||
|
rl.question('> ', (input) => {
|
||||||
|
const [cmd, ...args] = input.trim().split(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch(cmd.toLowerCase()) {
|
||||||
|
case 'list':
|
||||||
|
const users = db.prepare('SELECT id, namaLengkap, namaUser, email FROM users').all();
|
||||||
|
console.table(users);
|
||||||
|
break;
|
||||||
|
case 'count':
|
||||||
|
const count = db.prepare('SELECT COUNT(*) as total FROM users').get();
|
||||||
|
console.log(`Total users: ${count.total}`);
|
||||||
|
break;
|
||||||
|
case 'schema':
|
||||||
|
const schema = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
console.table(schema);
|
||||||
|
break;
|
||||||
|
case 'query':
|
||||||
|
const sql = args.join(' ');
|
||||||
|
if (!sql) {
|
||||||
|
console.log('Error: Please provide SQL query');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const result = db.prepare(sql).all();
|
||||||
|
console.table(result);
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
db.close();
|
||||||
|
rl.close();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
console.log('Unknown command. Type "exit" to quit.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt();
|
||||||
|
```
|
||||||
|
|
||||||
|
Jalankan dengan: `node scripts/db-access.js`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Checklist Perubahan Domain
|
||||||
|
|
||||||
|
Ketika mengubah domain aplikasi, ikuti checklist berikut:
|
||||||
|
|
||||||
|
## 1. File Environment (.env)
|
||||||
|
|
||||||
|
**Ubah `AUTH_ORIGIN` sesuai domain baru:**
|
||||||
|
|
||||||
|
### Development:
|
||||||
|
```env
|
||||||
|
AUTH_ORIGIN="https://antrean.dev.rssa.id"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production:
|
||||||
|
```env
|
||||||
|
AUTH_ORIGIN="https://antrean.rssa.id"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lokasi:** `.env` (atau `.env.development` / `.env.production`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Keycloak Configuration
|
||||||
|
|
||||||
|
**Di Keycloak Admin Console → Clients → [Your Client] → Settings:**
|
||||||
|
|
||||||
|
### Valid Redirect URIs:
|
||||||
|
Tambahkan:
|
||||||
|
- `https://antrean.dev.rssa.id/api/auth/keycloak-callback` (development)
|
||||||
|
- `https://antrean.rssa.id/api/auth/keycloak-callback` (production)
|
||||||
|
|
||||||
|
### Valid Post Logout Redirect URIs:
|
||||||
|
Tambahkan:
|
||||||
|
- `https://antrean.dev.rssa.id/LoginPage*` (development)
|
||||||
|
- `https://antrean.rssa.id/LoginPage*` (production)
|
||||||
|
|
||||||
|
### Web Origins:
|
||||||
|
Tambahkan:
|
||||||
|
- `https://antrean.dev.rssa.id` (development)
|
||||||
|
- `https://antrean.rssa.id` (production)
|
||||||
|
|
||||||
|
**Catatan:** Gunakan wildcard `*` untuk post logout redirect agar bisa handle query parameters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. nuxt.config.ts (Opsional)
|
||||||
|
|
||||||
|
**Jika menggunakan IP address untuk development:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
devServer: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0' // atau IP address jika perlu
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Untuk production dengan domain, biasanya tidak perlu diubah.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Kubernetes Configuration
|
||||||
|
|
||||||
|
### 4.1. ConfigMap (untuk non-sensitive environment variables)
|
||||||
|
|
||||||
|
**Buat atau update ConfigMap:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: antrean-config
|
||||||
|
namespace: default # atau namespace Anda
|
||||||
|
data:
|
||||||
|
AUTH_ORIGIN: "https://antrean.rssa.id" # atau https://antrean.dev.rssa.id untuk dev
|
||||||
|
KEYCLOAK_ISSUER: "https://auth.rssa.top/realms/sandbox"
|
||||||
|
KEYCLOAK_CLIENT_ID: "akbar-test"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Atau gunakan kubectl:**
|
||||||
|
```bash
|
||||||
|
kubectl create configmap antrean-config \
|
||||||
|
--from-literal=AUTH_ORIGIN=https://antrean.rssa.id \
|
||||||
|
--from-literal=KEYCLOAK_ISSUER=https://auth.rssa.top/realms/sandbox \
|
||||||
|
--from-literal=KEYCLOAK_CLIENT_ID=akbar-test \
|
||||||
|
-n <your-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Secret (untuk sensitive data)
|
||||||
|
|
||||||
|
**Buat Secret untuk credentials:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: antrean-secrets
|
||||||
|
namespace: default
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
KEYCLOAK_CLIENT_SECRET: "your-secret-here"
|
||||||
|
NUXT_AUTH_SECRET: "your-super-secret-string-of-at-least-32-characters"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Atau gunakan kubectl:**
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic antrean-secrets \
|
||||||
|
--from-literal=KEYCLOAK_CLIENT_SECRET=your-secret \
|
||||||
|
--from-literal=NUXT_AUTH_SECRET=your-auth-secret \
|
||||||
|
-n <your-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3. Ingress (untuk domain routing)
|
||||||
|
|
||||||
|
**Update Ingress dengan domain baru:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: antrean-ingress
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod" # atau issuer Anda
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx # atau ingress class Anda
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- antrean.rssa.id
|
||||||
|
- antrean.dev.rssa.id
|
||||||
|
secretName: antrean-tls-secret
|
||||||
|
rules:
|
||||||
|
- host: antrean.rssa.id
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: antrean-service
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
- host: antrean.dev.rssa.id
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: antrean-dev-service
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4. Deployment (update environment variables)
|
||||||
|
|
||||||
|
**Update Deployment untuk menggunakan ConfigMap dan Secret:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: antrean-app
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: antrean
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: antrean
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: antrean
|
||||||
|
image: your-registry/antrean:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
# Dari ConfigMap
|
||||||
|
- name: AUTH_ORIGIN
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: antrean-config
|
||||||
|
key: AUTH_ORIGIN
|
||||||
|
- name: KEYCLOAK_ISSUER
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: antrean-config
|
||||||
|
key: KEYCLOAK_ISSUER
|
||||||
|
- name: KEYCLOAK_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: antrean-config
|
||||||
|
key: KEYCLOAK_CLIENT_ID
|
||||||
|
# Dari Secret
|
||||||
|
- name: KEYCLOAK_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: antrean-secrets
|
||||||
|
key: KEYCLOAK_CLIENT_SECRET
|
||||||
|
- name: NUXT_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: antrean-secrets
|
||||||
|
key: NUXT_AUTH_SECRET
|
||||||
|
envFrom:
|
||||||
|
# Atau load semua dari ConfigMap (opsional)
|
||||||
|
# - configMapRef:
|
||||||
|
# name: antrean-config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Atau update dengan kubectl:**
|
||||||
|
```bash
|
||||||
|
kubectl set env deployment/antrean-app \
|
||||||
|
AUTH_ORIGIN=https://antrean.rssa.id \
|
||||||
|
--from=configmap/antrean-config \
|
||||||
|
-n <your-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5. Service (biasanya tidak perlu diubah)
|
||||||
|
|
||||||
|
Service biasanya tidak perlu diubah karena hanya routing internal.
|
||||||
|
|
||||||
|
### 4.6. Rollout/Restart Deployment
|
||||||
|
|
||||||
|
**Setelah update ConfigMap/Secret, restart pods:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Method 1: Rolling restart
|
||||||
|
kubectl rollout restart deployment/antrean-app -n <your-namespace>
|
||||||
|
|
||||||
|
# Method 2: Delete pods (akan auto-recreate)
|
||||||
|
kubectl delete pods -l app=antrean -n <your-namespace>
|
||||||
|
|
||||||
|
# Method 3: Scale down then up
|
||||||
|
kubectl scale deployment antrean-app --replicas=0 -n <your-namespace>
|
||||||
|
kubectl scale deployment antrean-app --replicas=2 -n <your-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7. Verifikasi di Kubernetes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cek ConfigMap
|
||||||
|
kubectl get configmap antrean-config -n <namespace> -o yaml
|
||||||
|
|
||||||
|
# Cek Secret (values akan di-encode base64)
|
||||||
|
kubectl get secret antrean-secrets -n <namespace> -o yaml
|
||||||
|
|
||||||
|
# Cek Ingress
|
||||||
|
kubectl get ingress antrean-ingress -n <namespace>
|
||||||
|
|
||||||
|
# Cek pods environment
|
||||||
|
kubectl exec -it <pod-name> -n <namespace> -- env | grep AUTH_ORIGIN
|
||||||
|
|
||||||
|
# Cek logs
|
||||||
|
kubectl logs -f deployment/antrean-app -n <namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Server/Deployment Configuration (Non-Kubernetes)
|
||||||
|
|
||||||
|
### Nginx/Reverse Proxy (jika ada):
|
||||||
|
- Update `server_name` dengan domain baru
|
||||||
|
- Update SSL certificate untuk domain baru
|
||||||
|
- Pastikan proxy_pass mengarah ke aplikasi yang benar
|
||||||
|
|
||||||
|
### Docker (jika ada):
|
||||||
|
- Update environment variables di docker-compose.yml atau Dockerfile
|
||||||
|
- Update port mapping jika perlu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DNS Configuration
|
||||||
|
|
||||||
|
- Pastikan domain sudah pointing ke IP server yang benar
|
||||||
|
- Pastikan A record atau CNAME sudah dikonfigurasi
|
||||||
|
- Tunggu DNS propagation (bisa beberapa menit sampai 24 jam)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SSL Certificate
|
||||||
|
|
||||||
|
- Pastikan SSL certificate sudah diinstal untuk domain baru
|
||||||
|
- Pastikan certificate valid dan tidak expired
|
||||||
|
- Untuk production, gunakan Let's Encrypt atau certificate resmi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Restart Server/Deployment
|
||||||
|
|
||||||
|
**PENTING:** Setelah mengubah `.env`:
|
||||||
|
1. Stop server (Ctrl+C)
|
||||||
|
2. Start server lagi (`npm run dev` atau `npm run build && npm start`)
|
||||||
|
|
||||||
|
Environment variables hanya dimuat saat server start!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Verifikasi
|
||||||
|
|
||||||
|
Setelah semua perubahan, verifikasi:
|
||||||
|
|
||||||
|
1. **Cek log server saat login:**
|
||||||
|
```
|
||||||
|
🔧 AUTH_ORIGIN from config: https://antrean.dev.rssa.id
|
||||||
|
🔗 Redirect URI being sent to Keycloak: https://antrean.dev.rssa.id/api/auth/keycloak-callback
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test login flow:**
|
||||||
|
- Login harus redirect ke Keycloak
|
||||||
|
- Setelah login, harus kembali ke aplikasi
|
||||||
|
- Tidak ada error "Invalid redirect URI"
|
||||||
|
|
||||||
|
3. **Test logout flow:**
|
||||||
|
- Logout harus redirect ke Keycloak
|
||||||
|
- Setelah logout, harus kembali ke login page
|
||||||
|
- Tidak ada error "Invalid redirect URI"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File yang TIDAK Perlu Diubah
|
||||||
|
|
||||||
|
✅ **Kode aplikasi** - Sudah menggunakan `config.public.authUrl` dari environment variable
|
||||||
|
✅ **Server API handlers** - Sudah menggunakan `config.public.authUrl`
|
||||||
|
✅ **Components** - Tidak ada hardcoded domain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contoh Konfigurasi Lengkap
|
||||||
|
|
||||||
|
### Development (.env.development):
|
||||||
|
```env
|
||||||
|
AUTH_ORIGIN="https://antrean.dev.rssa.id"
|
||||||
|
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||||
|
KEYCLOAK_CLIENT_SECRET="your-secret"
|
||||||
|
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||||
|
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (.env.production):
|
||||||
|
```env
|
||||||
|
AUTH_ORIGIN="https://antrean.rssa.id"
|
||||||
|
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||||
|
KEYCLOAK_CLIENT_SECRET="your-secret"
|
||||||
|
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||||
|
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Masih redirect ke domain lama?
|
||||||
|
- ✅ Pastikan server sudah restart
|
||||||
|
- ✅ Cek `.env` file sudah benar
|
||||||
|
- ✅ Clear browser cache
|
||||||
|
- ✅ Cek Keycloak configuration sudah benar
|
||||||
|
|
||||||
|
### Error "Invalid redirect URI"?
|
||||||
|
- ✅ Pastikan URI sudah ditambahkan di Keycloak
|
||||||
|
- ✅ Pastikan format URI sama persis (dengan/tanpa trailing slash)
|
||||||
|
- ✅ Pastikan menggunakan HTTPS jika domain menggunakan HTTPS
|
||||||
|
|
||||||
|
### Session tidak tersimpan?
|
||||||
|
- ✅ Pastikan cookie settings sesuai (secure: true untuk HTTPS)
|
||||||
|
- ✅ Cek browser console untuk cookie errors
|
||||||
|
- ✅ Pastikan domain di cookie sesuai dengan domain aplikasi
|
||||||
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# Dashboard Dummy Data Documentation
|
||||||
|
|
||||||
|
Dokumentasi lengkap data dummy yang digunakan di `pages/Dashboard.vue`
|
||||||
|
|
||||||
|
## 📊 Statistics Cards (Header Mini Stats)
|
||||||
|
|
||||||
|
Data statistik mini yang ditampilkan di header:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
pasienHariIni: 324,
|
||||||
|
antreanAktif: 47
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Main Statistics Cards
|
||||||
|
|
||||||
|
Data untuk 4 kartu statistik utama:
|
||||||
|
|
||||||
|
### 1. Pasien Hari Ini
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
label: 'Pasien Hari Ini',
|
||||||
|
value: '324',
|
||||||
|
icon: 'mdi-account-heart',
|
||||||
|
iconColor: 'white',
|
||||||
|
color: 'primary',
|
||||||
|
change: '+18.2%',
|
||||||
|
changeType: 'positive',
|
||||||
|
changeIcon: 'mdi-trending-up'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Antrean Aktif
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
label: 'Antrean Aktif',
|
||||||
|
value: '47',
|
||||||
|
icon: 'mdi-clock-fast',
|
||||||
|
iconColor: 'white',
|
||||||
|
color: 'secondary',
|
||||||
|
change: '+5',
|
||||||
|
changeType: 'positive',
|
||||||
|
changeIcon: 'mdi-arrow-up'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Rata-rata Tunggu
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
label: 'Rata-rata Tunggu',
|
||||||
|
value: '12 min',
|
||||||
|
icon: 'mdi-timer-outline',
|
||||||
|
iconColor: 'white',
|
||||||
|
color: 'success',
|
||||||
|
change: '-3 min',
|
||||||
|
changeType: 'positive',
|
||||||
|
changeIcon: 'mdi-trending-down'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Tingkat Kehadiran
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
label: 'Tingkat Kehadiran',
|
||||||
|
value: '89.5%',
|
||||||
|
icon: 'mdi-check-circle',
|
||||||
|
iconColor: 'white',
|
||||||
|
color: 'primary',
|
||||||
|
change: '+2.3%',
|
||||||
|
changeType: 'positive',
|
||||||
|
changeIcon: 'mdi-trending-up'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Chart 1: Tren Kunjungan Pasien (Line/Area Chart)
|
||||||
|
|
||||||
|
Chart yang menampilkan tren kunjungan pasien bulanan untuk tahun 2025.
|
||||||
|
|
||||||
|
### Labels (Bulan)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
[
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"Mei",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Ags",
|
||||||
|
"Sep",
|
||||||
|
"Okt",
|
||||||
|
"Nov",
|
||||||
|
"Des",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dataset 1: Pasien Umum
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
label: 'Pasien Umum',
|
||||||
|
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
|
||||||
|
backgroundColor: 'rgba(86, 126, 231, 0.2)',
|
||||||
|
borderColor: '#567EE7' // primary-500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total Pasien Umum 2025**: 13,070 pasien
|
||||||
|
|
||||||
|
### Dataset 2: Pasien BPJS
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
label: 'Pasien BPJS',
|
||||||
|
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
|
||||||
|
backgroundColor: 'rgba(255, 132, 65, 0.2)',
|
||||||
|
borderColor: '#FF8441' // secondary-500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total Pasien BPJS 2025**: 18,790 pasien
|
||||||
|
|
||||||
|
**Grand Total**: 31,860 pasien sepanjang tahun 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 Chart 2: Status Pembayaran (Doughnut Chart)
|
||||||
|
|
||||||
|
Distribusi metode pembayaran pasien.
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
|
||||||
|
datasets: [{
|
||||||
|
data: [1850, 680, 320, 280]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breakdown Persentase
|
||||||
|
|
||||||
|
| Metode Pembayaran | Jumlah | Persentase |
|
||||||
|
| ----------------- | --------- | ---------- |
|
||||||
|
| BPJS Kesehatan | 1,850 | 58.6% |
|
||||||
|
| Umum/Tunai | 680 | 21.5% |
|
||||||
|
| Asuransi Swasta | 320 | 10.1% |
|
||||||
|
| Corporate | 280 | 8.9% |
|
||||||
|
| **Total** | **3,130** | **100%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ Chart 3: Waktu Tunggu per Poli (Horizontal Bar Chart)
|
||||||
|
|
||||||
|
Rata-rata waktu tunggu untuk setiap poliklinik (dalam menit).
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Waktu Tunggu (menit)',
|
||||||
|
data: [15, 12, 8, 18, 10, 22]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ranking Waktu Tunggu
|
||||||
|
|
||||||
|
| Rank | Poliklinik | Waktu Tunggu |
|
||||||
|
| ---- | ------------ | ------------ |
|
||||||
|
| 1 | Poli Gigi | 8 menit ⚡ |
|
||||||
|
| 2 | Poli THT | 10 menit |
|
||||||
|
| 3 | Poli Anak | 12 menit |
|
||||||
|
| 4 | Poli Umum | 15 menit |
|
||||||
|
| 5 | Poli Mata | 18 menit |
|
||||||
|
| 6 | Poli Jantung | 22 menit 🐌 |
|
||||||
|
|
||||||
|
**Rata-rata Total**: 14.17 menit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Chart 4: Tingkat Kehadiran (Polar Area Chart)
|
||||||
|
|
||||||
|
Status kehadiran pasien dalam seminggu.
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
|
||||||
|
datasets: [{
|
||||||
|
data: [1850, 420, 280, 150, 230]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breakdown Persentase
|
||||||
|
|
||||||
|
| Status | Jumlah | Persentase |
|
||||||
|
| -------------------- | --------- | ---------- |
|
||||||
|
| Hadir Tepat Waktu ✅ | 1,850 | 62.6% |
|
||||||
|
| Hadir Terlambat ⏰ | 420 | 14.2% |
|
||||||
|
| Tidak Hadir ❌ | 280 | 9.5% |
|
||||||
|
| Batal 🚫 | 150 | 5.1% |
|
||||||
|
| Reschedule 📅 | 230 | 7.8% |
|
||||||
|
| **Total** | **2,960** | **100%** |
|
||||||
|
|
||||||
|
**Tingkat Kehadiran Total** (Hadir Tepat Waktu + Hadir Terlambat): 76.8%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Color Palette
|
||||||
|
|
||||||
|
Palet warna yang digunakan dalam dashboard:
|
||||||
|
|
||||||
|
### Primary Colors
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
700: '#3556AE',
|
||||||
|
600: '#3A61C9',
|
||||||
|
500: '#567EE7',
|
||||||
|
400: '#7898EC',
|
||||||
|
300: '#9AB2F1',
|
||||||
|
200: '#BBCBF5'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secondary Colors
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
700: '#E65A0D',
|
||||||
|
600: '#F16F29',
|
||||||
|
500: '#FF8441',
|
||||||
|
400: '#FF9D67',
|
||||||
|
300: '#FFB58D'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Colors
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
700: '#008D65',
|
||||||
|
600: '#33A484',
|
||||||
|
500: '#59B59B',
|
||||||
|
400: '#80C6B2'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neutral Colors
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
100: '#FFFFFF',
|
||||||
|
300: '#F5F7FA',
|
||||||
|
500: '#CDD4DC',
|
||||||
|
700: '#717171',
|
||||||
|
900: '#212121'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 Export Options
|
||||||
|
|
||||||
|
Menu export data yang tersedia:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
[
|
||||||
|
{ 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" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary Insights
|
||||||
|
|
||||||
|
### Total Visitors
|
||||||
|
|
||||||
|
- **Hari Ini**: 324 pasien (+18.2%)
|
||||||
|
- **Bulan Ini** (estimasi): ~2,617 pasien
|
||||||
|
- **Tahun 2025** (dari chart): 31,860 pasien
|
||||||
|
|
||||||
|
### Performance Metrics
|
||||||
|
|
||||||
|
- **Antrean Aktif**: 47 (+5)
|
||||||
|
- **Rata-rata Waktu Tunggu**: 12 menit (-3 min improvement)
|
||||||
|
- **Tingkat Kehadiran**: 89.5% (+2.3%)
|
||||||
|
- **Kehadiran Tepat Waktu**: 62.6%
|
||||||
|
|
||||||
|
### Top Performing Clinic
|
||||||
|
|
||||||
|
- **Tercepat**: Poli Gigi (8 menit)
|
||||||
|
- **Terlambat**: Poli Jantung (22 menit)
|
||||||
|
|
||||||
|
### Payment Distribution
|
||||||
|
|
||||||
|
- **BPJS dominan**: 58.6% dari total transaksi
|
||||||
|
- **Non-BPJS**: 41.4% (Umum + Asuransi + Corporate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Usage Notes
|
||||||
|
|
||||||
|
File ini mendokumentasikan semua data dummy yang digunakan dalam komponen Dashboard.vue. Data ini dapat digunakan sebagai:
|
||||||
|
|
||||||
|
1. **Reference** untuk struktur data API
|
||||||
|
2. **Testing data** untuk pengembangan
|
||||||
|
3. **Template** untuk implementasi data real dari backend
|
||||||
|
4. **Documentation** untuk tim pengembang
|
||||||
|
|
||||||
|
Semua data bersifat **dummy/sample** dan harus diganti dengan data real dari API/backend saat production.
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# Dockerfile for web-antrean Nuxt.js Application
|
||||||
|
# Multi-stage build for optimized image size and native modules support
|
||||||
|
|
||||||
|
# Stage 1: Build Stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies for compiling native addons (like better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++ gcc libc-dev
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies (including devDependencies for build)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application for production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production Stage
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
# Install runtime dependencies (like libc6-compat for compatibility with precompiled binaries)
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Copy built output and compiled node_modules from builder stage
|
||||||
|
# This avoids needing to install build tools (g++, make, python) in this slim production stage
|
||||||
|
COPY --from=builder /app/.output /app/.output
|
||||||
|
COPY --from=builder /app/node_modules /app/node_modules
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
# API Endpoint Documentation - Hak Akses
|
||||||
|
|
||||||
|
## Deskripsi
|
||||||
|
|
||||||
|
Dokumentasi ini menjelaskan semua API endpoint yang diperlukan untuk fitur **Hak Akses** di halaman `/pages/Setting/HakAkses.vue`. Endpoint-endpoint ini akan dihubungkan ke backend API yang disediakan oleh tim backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
{API_BASE_URL}/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Catatan:** Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya (contoh: `http://10.10.123.140:8089/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get List Users
|
||||||
|
|
||||||
|
Mengambil daftar semua users beserta roles, groups, dan informasi lainnya yang diperlukan untuk form hak akses.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/users/list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:** Tidak ad
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "user-123",
|
||||||
|
"namaLengkap": "John Doe",
|
||||||
|
"namaUser": "johndoe",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"tipeUser": "Super Admin",
|
||||||
|
"lastLogin": 1704067200,
|
||||||
|
"roles": ["superadmin", "admin"],
|
||||||
|
"realmRoles": ["default-roles-sandbox"],
|
||||||
|
"accountRoles": [],
|
||||||
|
"resourceRoles": [],
|
||||||
|
"groups": ["/Instalasi STIM/Devops/Superadmin", "/Instalasi STIM/Admin"],
|
||||||
|
"given_name": "John",
|
||||||
|
"family_name": "Doe",
|
||||||
|
"createdAt": 1704067200,
|
||||||
|
"updatedAt": 1704067200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (500 Internal Server Error):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 500,
|
||||||
|
"statusMessage": "Failed to fetch users"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Description
|
||||||
|
|
||||||
|
- `id`: Unique identifier user
|
||||||
|
- `namaLengkap`: Nama lengkap user
|
||||||
|
- `namaUser`: Username untuk login
|
||||||
|
- `email`: Email user
|
||||||
|
- `tipeUser`: Tipe/jenis user (contoh: "Super Admin", "Admin Loket", dll)
|
||||||
|
- `lastLogin`: Timestamp terakhir login (Unix timestamp dalam detik)
|
||||||
|
- `roles`: Array of role names
|
||||||
|
- `realmRoles`: Array of realm roles dari Keycloak
|
||||||
|
- `accountRoles`: Array of account roles
|
||||||
|
- `resourceRoles`: Array of resource roles
|
||||||
|
- `groups`: Array of group paths (contoh: "/Instalasi STIM/Devops/Superadmin")
|
||||||
|
- `given_name`: Nama depan
|
||||||
|
- `family_name`: Nama belakang
|
||||||
|
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
|
||||||
|
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Get Permissions by Role and Group
|
||||||
|
|
||||||
|
Mengambil daftar permissions dari backend berdasarkan role dan group yang dipilih.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/permission
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `roles` | string | Yes | Role name (contoh: "superadmin", "admin") |
|
||||||
|
| `groups` | string | Yes | Group name atau group path (contoh: "STIM", "/Instalasi STIM/Devops/Superadmin") |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/permission?roles=superadmin&groups=STIM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Data permission berhasil diambil",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"create": false,
|
||||||
|
"read": true,
|
||||||
|
"update": false,
|
||||||
|
"disable": false,
|
||||||
|
"delete": false,
|
||||||
|
"active": true,
|
||||||
|
"pagename": "Halaman Utama",
|
||||||
|
"pagesID": 1,
|
||||||
|
"level": 1,
|
||||||
|
"sort": 1,
|
||||||
|
"parent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"create": false,
|
||||||
|
"read": true,
|
||||||
|
"update": false,
|
||||||
|
"disable": false,
|
||||||
|
"delete": false,
|
||||||
|
"active": true,
|
||||||
|
"pagename": "Pengaturan",
|
||||||
|
"pagesID": 2,
|
||||||
|
"level": 1,
|
||||||
|
"sort": 2,
|
||||||
|
"parent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"create": true,
|
||||||
|
"read": true,
|
||||||
|
"update": true,
|
||||||
|
"disable": true,
|
||||||
|
"delete": false,
|
||||||
|
"active": true,
|
||||||
|
"pagename": "Hak Akses",
|
||||||
|
"pagesID": 3,
|
||||||
|
"level": 2,
|
||||||
|
"sort": 3,
|
||||||
|
"parent": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"count": 3,
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (400 Bad Request):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"statusMessage": "roles or groups parameter is required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (500 Internal Server Error):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Failed to fetch permissions",
|
||||||
|
"data": [],
|
||||||
|
"meta": {
|
||||||
|
"count": 0,
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Description
|
||||||
|
|
||||||
|
- `id`: Unique identifier permission
|
||||||
|
- `create`: Boolean, permission untuk create/tambah data
|
||||||
|
- `read`: Boolean, permission untuk read/lihat data
|
||||||
|
- `update`: Boolean, permission untuk update/edit data
|
||||||
|
- `disable`: Boolean, permission untuk disable/nonaktifkan
|
||||||
|
- `delete`: Boolean, permission untuk delete/hapus data
|
||||||
|
- `active`: Boolean, status aktif permission
|
||||||
|
- `pagename`: Nama halaman/menu (akan di-mapping ke menu sidebar)
|
||||||
|
- `pagesID`: ID halaman di sistem backend
|
||||||
|
- `level`: Level hierarki menu (1 = parent, 2 = child, dll)
|
||||||
|
- `sort`: Urutan tampil
|
||||||
|
- `parent`: ID parent menu (null jika parent menu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Get List Hak Akses
|
||||||
|
|
||||||
|
Mengambil daftar semua hak akses yang sudah tersimpan.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/hak-akses
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:** Tidak ada
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"userId": "user-123",
|
||||||
|
"namaLengkap": "John Doe",
|
||||||
|
"namaUser": "johndoe",
|
||||||
|
"tipeUser": "Super Admin",
|
||||||
|
"role": "superadmin",
|
||||||
|
"group": "STIM",
|
||||||
|
"groupPath": "/Instalasi STIM/Devops/Superadmin",
|
||||||
|
"namaTipeUser": "Super Admin",
|
||||||
|
"hakAksesMenu": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": false,
|
||||||
|
"canEdit": false,
|
||||||
|
"canDelete": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Master Data",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": true,
|
||||||
|
"canEdit": true,
|
||||||
|
"canDelete": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isGroupBased": true,
|
||||||
|
"createdAt": 1704067200,
|
||||||
|
"updatedAt": 1704067200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (500 Internal Server Error):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 500,
|
||||||
|
"statusMessage": "Failed to fetch hak akses"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Description
|
||||||
|
|
||||||
|
- `id`: Unique identifier hak akses
|
||||||
|
- `userId`: ID user (optional, untuk backward compatibility)
|
||||||
|
- `namaLengkap`: Nama lengkap user (optional)
|
||||||
|
- `namaUser`: Username (optional)
|
||||||
|
- `tipeUser`: Tipe user (optional)
|
||||||
|
- `role`: Role name (required)
|
||||||
|
- `group`: Group name (required)
|
||||||
|
- `groupPath`: Full group path (optional, untuk group-based access)
|
||||||
|
- `namaTipeUser`: Nama tipe user (optional)
|
||||||
|
- `hakAksesMenu`: Array of menu permissions
|
||||||
|
- `name`: Nama menu
|
||||||
|
- `canAccess`: Boolean, apakah bisa akses menu
|
||||||
|
- `canView`: Boolean, apakah bisa lihat data
|
||||||
|
- `canAdd`: Boolean, apakah bisa tambah data
|
||||||
|
- `canEdit`: Boolean, apakah bisa edit data
|
||||||
|
- `canDelete`: Boolean, apakah bisa hapus data
|
||||||
|
- `isGroupBased`: Boolean, apakah hak akses ini group-based (true) atau individual (false)
|
||||||
|
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
|
||||||
|
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create Hak Akses
|
||||||
|
|
||||||
|
Membuat hak akses baru (group-based atau individual).
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/hak-akses
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "superadmin",
|
||||||
|
"group": "STIM",
|
||||||
|
"groupPath": "/Instalasi STIM/Devops/Superadmin",
|
||||||
|
"namaTipeUser": "Super Admin",
|
||||||
|
"hakAksesMenu": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": false,
|
||||||
|
"canEdit": false,
|
||||||
|
"canDelete": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Master Data",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": true,
|
||||||
|
"canEdit": true,
|
||||||
|
"canDelete": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isGroupBased": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (201 Created):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Hak akses berhasil dibuat",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"role": "superadmin",
|
||||||
|
"group": "STIM",
|
||||||
|
"groupPath": "/Instalasi STIM/Devops/Superadmin",
|
||||||
|
"namaTipeUser": "Super Admin",
|
||||||
|
"hakAksesMenu": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": false,
|
||||||
|
"canEdit": false,
|
||||||
|
"canDelete": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isGroupBased": true,
|
||||||
|
"createdAt": 1704067200,
|
||||||
|
"updatedAt": 1704067200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (400 Bad Request):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"statusMessage": "role and group are required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (409 Conflict):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 409,
|
||||||
|
"statusMessage": "Hak akses untuk group dan role ini sudah ada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Description
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
|
||||||
|
- `role`: Role name (string, required)
|
||||||
|
- `group`: Group name (string, required)
|
||||||
|
- `hakAksesMenu`: Array of menu permissions (array, required)
|
||||||
|
|
||||||
|
**Optional Fields:**
|
||||||
|
|
||||||
|
- `userId`: ID user (string, optional)
|
||||||
|
- `namaLengkap`: Nama lengkap (string, optional)
|
||||||
|
- `namaUser`: Username (string, optional)
|
||||||
|
- `tipeUser`: Tipe user (string, optional)
|
||||||
|
- `groupPath`: Full group path (string, optional)
|
||||||
|
- `namaTipeUser`: Nama tipe user (string, optional)
|
||||||
|
- `isGroupBased`: Boolean, default true (boolean, optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Update Hak Akses
|
||||||
|
|
||||||
|
Mengupdate hak akses yang sudah ada.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/hak-akses/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | ID hak akses yang akan diupdate |
|
||||||
|
|
||||||
|
**Body (semua field optional, hanya kirim field yang ingin diupdate):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "admin",
|
||||||
|
"group": "LOKET",
|
||||||
|
"namaTipeUser": "Admin Loket",
|
||||||
|
"hakAksesMenu": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": false,
|
||||||
|
"canEdit": false,
|
||||||
|
"canDelete": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Hak akses berhasil diperbarui",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"role": "admin",
|
||||||
|
"group": "LOKET",
|
||||||
|
"groupPath": "/Instalasi STIM/Loket",
|
||||||
|
"namaTipeUser": "Admin Loket",
|
||||||
|
"hakAksesMenu": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": false,
|
||||||
|
"canEdit": false,
|
||||||
|
"canDelete": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isGroupBased": true,
|
||||||
|
"createdAt": 1704067200,
|
||||||
|
"updatedAt": 1704067300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (400 Bad Request):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"statusMessage": "ID is required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (404 Not Found):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 404,
|
||||||
|
"statusMessage": "Hak akses not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (500 Internal Server Error):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 500,
|
||||||
|
"statusMessage": "Failed to update hak akses"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Delete Hak Akses
|
||||||
|
|
||||||
|
Menghapus hak akses.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/hak-akses/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | ID hak akses yang akan dihapus |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Hak akses berhasil dihapus"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (400 Bad Request):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"statusMessage": "ID is required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (404 Not Found):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 404,
|
||||||
|
"statusMessage": "Hak akses not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (500 Internal Server Error):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 500,
|
||||||
|
"statusMessage": "Failed to delete hak akses"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapping Permissions ke Menu Sidebar
|
||||||
|
|
||||||
|
Sistem akan otomatis memetakan `pagename` dari API permission ke nama menu di sidebar. Mapping dilakukan dengan:
|
||||||
|
|
||||||
|
1. **Exact match**: `pagename.toLowerCase() === menu.name.toLowerCase()`
|
||||||
|
2. **Partial match**: `pagename.toLowerCase().includes(menu.name.toLowerCase())` atau sebaliknya
|
||||||
|
|
||||||
|
### Contoh Mapping:
|
||||||
|
|
||||||
|
- `pagename: "Halaman Utama"` → Menu: "Dashboard"
|
||||||
|
- `pagename: "Pengaturan"` → Menu: "Master Data"
|
||||||
|
- `pagename: "Hak Akses"` → Menu: "Hak Akses"
|
||||||
|
|
||||||
|
### Permission Mapping:
|
||||||
|
|
||||||
|
- `read: true` → `canView: true`
|
||||||
|
- `create: true` → `canAdd: true`
|
||||||
|
- `update: true` → `canEdit: true`
|
||||||
|
- `delete: true` → `canDelete: true`
|
||||||
|
- `active: true || read: true` → `canAccess: true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group-Based Access
|
||||||
|
|
||||||
|
Sistem mendukung **Group-Based Access** dimana:
|
||||||
|
|
||||||
|
- Satu hak akses dapat diberikan ke semua user yang memiliki group dan role yang sama
|
||||||
|
- Setiap user dapat memiliki multiple hak akses dari berbagai group
|
||||||
|
- Hak akses disimpan dengan flag `isGroupBased: true` dan `groupPath` untuk identifikasi
|
||||||
|
|
||||||
|
### Contoh:
|
||||||
|
|
||||||
|
Jika hak akses dibuat dengan:
|
||||||
|
|
||||||
|
- `role: "superadmin"`
|
||||||
|
- `groupPath: "/Instalasi STIM/Devops/Superadmin"`
|
||||||
|
- `isGroupBased: true`
|
||||||
|
|
||||||
|
Maka semua user yang memiliki:
|
||||||
|
|
||||||
|
- Role: "superadmin" **DAN**
|
||||||
|
- Group: "/Instalasi STIM/Devops/Superadmin"
|
||||||
|
|
||||||
|
Akan otomatis mendapatkan hak akses tersebut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Semua endpoint harus mengembalikan error dengan format konsisten:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400|404|409|500,
|
||||||
|
"statusMessage": "Error message description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status Codes:**
|
||||||
|
|
||||||
|
- `200 OK`: Request berhasil
|
||||||
|
- `201 Created`: Resource berhasil dibuat
|
||||||
|
- `400 Bad Request`: Request tidak valid (missing required fields, invalid format)
|
||||||
|
- `404 Not Found`: Resource tidak ditemukan
|
||||||
|
- `409 Conflict`: Resource sudah ada (untuk create duplicate)
|
||||||
|
- `500 Internal Server Error`: Server error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
**Catatan:** Tim backend perlu menentukan apakah endpoint-endpoint ini memerlukan authentication token atau tidak. Jika diperlukan, tambahkan:
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Contoh Request dengan cURL:
|
||||||
|
|
||||||
|
**1. Get Users List:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://{API_BASE_URL}/api/users/list" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Get Permissions:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://{API_BASE_URL}/api/v1/permission?roles=superadmin&groups=STIM" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create Hak Akses:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://{API_BASE_URL}/api/v1/hak-akses" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"role": "superadmin",
|
||||||
|
"group": "STIM",
|
||||||
|
"groupPath": "/Instalasi STIM/Devops/Superadmin",
|
||||||
|
"namaTipeUser": "Super Admin",
|
||||||
|
"hakAksesMenu": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"canAccess": true,
|
||||||
|
"canView": true,
|
||||||
|
"canAdd": false,
|
||||||
|
"canEdit": false,
|
||||||
|
"canDelete": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isGroupBased": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Update Hak Akses:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH "http://{API_BASE_URL}/api/v1/hak-akses/1" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"namaTipeUser": "Updated Admin",
|
||||||
|
"hakAksesMenu": [...]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Delete Hak Akses:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://{API_BASE_URL}/api/v1/hak-akses/1" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
1. **Base URL**: Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya
|
||||||
|
2. **Timestamps**: Semua timestamp menggunakan format Unix timestamp (detik sejak epoch)
|
||||||
|
3. **Group Path**: Group path harus full path (contoh: "/Instalasi STIM/Devops/Superadmin") untuk group-based access
|
||||||
|
4. **Menu Permissions**: Array `hakAksesMenu` harus sesuai dengan struktur menu sidebar
|
||||||
|
5. **Validation**: Backend harus melakukan validasi untuk:
|
||||||
|
- Required fields (role, group, hakAksesMenu)
|
||||||
|
- Duplicate check untuk group+role combination (jika isGroupBased = true)
|
||||||
|
- Format data yang valid
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kontak
|
||||||
|
|
||||||
|
Jika ada pertanyaan atau perubahan requirement, silakan hubungi tim frontend.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Setup HTTPS untuk Development
|
||||||
|
|
||||||
|
Aplikasi ini sudah dikonfigurasi untuk menggunakan HTTPS di development mode agar fitur kamera bisa berfungsi.
|
||||||
|
|
||||||
|
## Cara Menggunakan
|
||||||
|
|
||||||
|
1. **Jalankan aplikasi dengan HTTPS:**
|
||||||
|
```bash
|
||||||
|
npm run dev:https
|
||||||
|
```
|
||||||
|
atau
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Akses aplikasi:**
|
||||||
|
- Dari komputer: `https://localhost:3001`
|
||||||
|
- Dari HP (dalam jaringan yang sama): `https://[IP-KOMPUTER]:3001`
|
||||||
|
- Contoh: `https://10.10.150.175:3001`
|
||||||
|
|
||||||
|
## Peringatan Keamanan Browser
|
||||||
|
|
||||||
|
Karena menggunakan self-signed certificate, browser akan menampilkan peringatan keamanan:
|
||||||
|
|
||||||
|
### Chrome/Edge:
|
||||||
|
1. Klik **"Advanced"** atau **"Lanjutkan ke localhost (tidak aman)"**
|
||||||
|
2. Klik **"Proceed to localhost (unsafe)"**
|
||||||
|
|
||||||
|
### Firefox:
|
||||||
|
1. Klik **"Advanced"**
|
||||||
|
2. Klik **"Accept the Risk and Continue"**
|
||||||
|
|
||||||
|
### Safari (Mac):
|
||||||
|
1. Klik **"Show Details"**
|
||||||
|
2. Klik **"visit this website"**
|
||||||
|
3. Klik **"Visit Website"** di dialog konfirmasi
|
||||||
|
|
||||||
|
### Mobile (HP):
|
||||||
|
- **Android Chrome**: Klik **"Advanced"** → **"Proceed to [IP] (unsafe)"**
|
||||||
|
- **iOS Safari**: Klik **"Advanced"** → **"Proceed to [IP]"**
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
- ✅ Setelah menerima certificate, browser akan mengingatnya untuk kunjungan selanjutnya
|
||||||
|
- ✅ HTTPS diperlukan untuk akses kamera di browser modern
|
||||||
|
- ✅ Pastikan HP dan komputer dalam jaringan yang sama (WiFi yang sama)
|
||||||
|
- ✅ Gunakan IP komputer Anda, bukan `localhost` saat akses dari HP
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Kamera masih tidak muncul?
|
||||||
|
1. Pastikan sudah menggunakan `https://` bukan `http://`
|
||||||
|
2. Cek izin kamera di browser settings
|
||||||
|
3. Pastikan tidak ada aplikasi lain yang menggunakan kamera
|
||||||
|
4. Restart browser setelah setup HTTPS pertama kali
|
||||||
|
|
||||||
|
### Tidak bisa akses dari HP?
|
||||||
|
1. Pastikan firewall tidak memblokir port 3001
|
||||||
|
2. Pastikan HP dan komputer dalam WiFi yang sama
|
||||||
|
3. Cek IP komputer dengan `ipconfig` (Windows) atau `ifconfig` (Mac/Linux)
|
||||||
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Pages List - Web Antrean Application
|
||||||
|
|
||||||
|
This document lists all pages in the application with their file paths and route paths.
|
||||||
|
|
||||||
|
## Root Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Home/Index | `pages/index.vue` | `/` |
|
||||||
|
| Login Page | `pages/LoginPage.vue` | `/login-page` |
|
||||||
|
| Dashboard | `pages/Dashboard.vue` | `/dashboard` |
|
||||||
|
| Admin Klinik | `pages/AdminKlinik.vue` | `/admin-klinik` |
|
||||||
|
| Admin Loket | `pages/AdminLoket.vue` | `/admin-loket` |
|
||||||
|
| Admin Penunjang | `pages/AdminPenunjang.vue` | `/admin-penunjang` |
|
||||||
|
| Buat Antrean | `pages/BuatAntrean.vue` | `/buat-antrean` |
|
||||||
|
| Klinik Ruang Admin | `pages/KlinikRuangAdmin.vue` | `/klinik-ruang-admin` |
|
||||||
|
| Ranap Admin | `pages/RanapAdmin.vue` | `/ranap-admin` |
|
||||||
|
|
||||||
|
## Anjungan Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Anjungan | `pages/Anjungan/Anjungan.vue` | `/anjungan` |
|
||||||
|
| Admin Anjungan | `pages/Anjungan/AdminAnjungan.vue` | `/anjungan/admin-anjungan` |
|
||||||
|
| Antrian Klinik | `pages/Anjungan/AntrianKlinik.vue` | `/anjungan/antrian-klinik` |
|
||||||
|
| Antrian Klinik Ruang | `pages/Anjungan/AntrianKlinikRuang.vue` | `/anjungan/antrian-klinik-ruang` |
|
||||||
|
| Antrian Penunjang | `pages/Anjungan/AntrianPenunjang.vue` | `/anjungan/antrian-penunjang` |
|
||||||
|
|
||||||
|
## Check In Pasien Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Check In | `pages/CheckInPasien/checkIn.vue` | `/check-in-pasien/check-in` |
|
||||||
|
|
||||||
|
## Data Pasien Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Data Pasien Index | `pages/data-pasien/index.vue` | `/data-pasien` |
|
||||||
|
| Edit Data Pasien | `pages/data-pasien/edit/[id].vue` | `/data-pasien/edit/:id` |
|
||||||
|
|
||||||
|
## Monitoring Pasien Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Monitoring Pasien | `pages/MonitoringPasien/monitoringPasien.vue` | `/monitoring-pasien/monitoring-pasien` |
|
||||||
|
| Detail Pasien | `pages/MonitoringPasien/pasien/[id].vue` | `/monitoring-pasien/pasien/:id` |
|
||||||
|
|
||||||
|
## Profile Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Profil | `pages/Profile/Profil.vue` | `/profile/profil` |
|
||||||
|
|
||||||
|
|
||||||
|
## Setting Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| User Login | `pages/Setting/UserLogin.vue` | `/setting/user-login` |
|
||||||
|
| Hak Akses | `pages/Setting/HakAkses.vue` | `/setting/hak-akses` |
|
||||||
|
| Master Klinik | `pages/Setting/MasterKlinik.vue` | `/setting/master-klinik` |
|
||||||
|
| Master Klinik Ruang | `pages/Setting/MasterKlinikRuang.vue` | `/setting/master-klinik-ruang` |
|
||||||
|
| Master Loket | `pages/Setting/MasterLoket.vue` | `/setting/master-loket` |
|
||||||
|
| Master Penunjang | `pages/Setting/MasterPenunjang.vue` | `/setting/master-penunjang` |
|
||||||
|
| Screen | `pages/Setting/Screen.vue` | `/setting/screen` |
|
||||||
|
| Tambah Loket | `pages/Setting/TambahLoket.vue` | `/setting/tambah-loket` |
|
||||||
|
| Edit Loket | `pages/Setting/Edit-Loket/[id].vue` | `/setting/edit-loket/:id` |
|
||||||
|
| Screen Index | `pages/Setting/Screen/index.vue` | `/setting/screen` |
|
||||||
|
| Edit Screen | `pages/Setting/Screen/edit/[id].vue` | `/setting/screen/edit/:id` |
|
||||||
|
|
||||||
|
## Verifikasi Akun Pages
|
||||||
|
|
||||||
|
| Page Name | File Path | Route Path |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Verifikasi Akun | `pages/verifikasiAkun/VerifikasiAkun.vue` | `/verifikasi-akun/verifikasi-akun` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- **Total Pages**: 30+ pages
|
||||||
|
- **Dynamic Routes**: 4 pages with `[id]` parameter
|
||||||
|
- **Note**: `Pengaturan.vue.txt` appears to be a text file, not an active Vue component
|
||||||
|
|
||||||
|
## Route Path Conversion Rules
|
||||||
|
|
||||||
|
In Nuxt.js, file paths are converted to routes as follows:
|
||||||
|
- File names are converted to kebab-case
|
||||||
|
- Folders create nested routes
|
||||||
|
- `index.vue` files create routes at the folder level
|
||||||
|
- `[id].vue` files create dynamic routes with `:id` parameter
|
||||||
|
- Special characters like hyphens are preserved
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 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.123.140:8089/api/v1/permission`) tidak dapat diakses.
|
||||||
|
|
||||||
|
### 2. Memaksa Menggunakan Placeholder API
|
||||||
|
|
||||||
|
Tambahkan query parameter `usePlaceholder=true` pada request:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Menonaktifkan Placeholder API
|
||||||
|
|
||||||
|
Tambahkan query parameter `usePlaceholder=false` pada request:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Placeholder yang Tersedia
|
||||||
|
|
||||||
|
### Role: superadmin, Group: STIM
|
||||||
|
|
||||||
|
Data placeholder mengembalikan 5 permission items:
|
||||||
|
|
||||||
|
- Halaman Utama (read: true, active: true)
|
||||||
|
- Pengaturan (read: true, active: true)
|
||||||
|
- Halaman (read: true, active: true, disable: true)
|
||||||
|
- Dashboard (read: true, active: true, disable: true)
|
||||||
|
|
||||||
|
## Mapping Pagename ke Menu Sidebar
|
||||||
|
|
||||||
|
Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
|
||||||
|
|
||||||
|
- "Halaman Utama" → "Dashboard"
|
||||||
|
- "Pengaturan" → "Master Data"
|
||||||
|
- "Halaman" → "Master Data"
|
||||||
|
- "Dashboard" → "Dashboard"
|
||||||
|
|
||||||
|
## Testing dengan User bayurssa
|
||||||
|
|
||||||
|
Untuk testing dengan user email "bayurssa":
|
||||||
|
|
||||||
|
1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak
|
||||||
|
2. Login dengan email "bayurssa" dan password "12345"
|
||||||
|
3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia
|
||||||
|
4. Sidebar akan terfilter berdasarkan permissions dari placeholder API
|
||||||
|
|
||||||
|
## Mapping Role dan Group
|
||||||
|
|
||||||
|
Sistem secara otomatis melakukan normalisasi untuk role dan group:
|
||||||
|
|
||||||
|
### Normalisasi Role
|
||||||
|
|
||||||
|
- `default-roles-sandbox` → `superadmin`
|
||||||
|
- Role lain akan digunakan apa adanya (lowercase)
|
||||||
|
|
||||||
|
### Normalisasi Group
|
||||||
|
|
||||||
|
- `Instalasi STIM` → `STIM`
|
||||||
|
- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM"
|
||||||
|
- Group lain akan digunakan apa adanya (uppercase)
|
||||||
|
|
||||||
|
### Contoh Mapping
|
||||||
|
|
||||||
|
- Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
|
||||||
|
- Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
|
||||||
|
|
||||||
|
## Catatan
|
||||||
|
|
||||||
|
- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan
|
||||||
|
- Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API
|
||||||
|
- Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API
|
||||||
|
- Jika backend API juga gagal, sistem akan mengembalikan data kosong
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Dokumentasi Implementasi Proxy API (Nuxt 3)
|
||||||
|
|
||||||
|
Dokumen ini menjelaskan cara menggunakan dan menambah proxy API di file `nuxt.config.ts` untuk menangani masalah CORS dan menyederhanakan pemanggilan endpoint backend.
|
||||||
|
|
||||||
|
## 1. Menambah Proxy Baru di `nuxt.config.ts`
|
||||||
|
|
||||||
|
Jika Anda memiliki API backend lain (misalnya departemen berbeda atau IP berbeda), Anda cukup menambah baris baru di dalam objek `routeRules`.
|
||||||
|
|
||||||
|
### Contoh Konfigurasi:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nuxt.config.ts
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
routeRules: {
|
||||||
|
// Proxy untuk Statistik (yang sudah ada)
|
||||||
|
"/stats-api/**": {
|
||||||
|
proxy: "http://10.10.150.100:8084/api/v1/**",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Proxy untuk API Pasien (Contoh Tambahan)
|
||||||
|
"/patient-api/**": {
|
||||||
|
proxy: "http://10.10.150.101:8000/api/v2/**",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Proxy untuk API Antrean (IP sama, prefix path berbeda)
|
||||||
|
"/queue-api/**": {
|
||||||
|
proxy: "http://10.10.150.100:8084/api/v1/queue/**",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cara Menggunakan di Composable
|
||||||
|
|
||||||
|
Setelah didaftarkan di `nuxt.config.ts`, Anda tidak perlu lagi menulis IP Address di dalam kode Vue/TypeScript Anda. Gunakan path prefix yang sudah didefinisikan.
|
||||||
|
|
||||||
|
### Contoh File: `composables/usePatientAPI.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const usePatientAPI = () => {
|
||||||
|
const fetchPatient = async (id: string) => {
|
||||||
|
// Alamat asli: http://10.10.150.101:8000/api/v2/patient/123
|
||||||
|
// Cukup panggil seperti ini:
|
||||||
|
return await $fetch(`/patient-api/patient/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fetchPatient };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Keuntungan Menggunakan Proxy
|
||||||
|
|
||||||
|
1. **Menghindari CORS**: Browser tidak akan memblokir request karena seolah-olah request dikirim ke server yang sama dengan frontend.
|
||||||
|
2. **Keamanan**: IP backend asli tidak terekspos secara langsung di kode frontend (Client-side).
|
||||||
|
3. **Kemudahan Maintenance**: Jika IP Server backend berubah, Anda hanya perlu mengubah satu baris di `nuxt.config.ts` tanpa harus mencari dan mengganti di seluruh file `.vue` atau `.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tips Debugging Proxy
|
||||||
|
|
||||||
|
- Jika menggunakan **Vite Dev Server**, pastikan Anda menjalankan ulang `npm run dev` setelah mengubah `nuxt.config.ts`.
|
||||||
|
- Cek tab **Network** di Developer Tools (F12). Anda akan melihat request ke `localhost:3000/stats-api/...` dan server Nuxt akan meneruskannya ke IP backend secara otomatis.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Panduan Deployment Docker - web-antrean
|
||||||
|
|
||||||
|
Panduan ini berisi langkah-langkah untuk menjalankan aplikasi `web-antrean` (Nuxt 3) menggunakan Docker & Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Prasyarat (Prerequisites)
|
||||||
|
|
||||||
|
Sebelum memulai, pastikan Anda telah menginstal:
|
||||||
|
- **Docker Desktop** (atau Docker Engine di Linux)
|
||||||
|
- **Docker Compose** (biasanya otomatis terinstal dengan Docker Desktop)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start (Mulai Cepat)
|
||||||
|
|
||||||
|
### 1. Siapkan File `.env`
|
||||||
|
Pastikan file `.env` sudah ada di root folder `web-antrean` dengan konfigurasi yang benar. Anda bisa menyalin dari template yang disediakan:
|
||||||
|
```bash
|
||||||
|
cp .env.docker.example .env
|
||||||
|
```
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Pastikan variabel `HOST` diatur ke `0.0.0.0` agar aplikasi dapat menerima request dari luar container Docker.
|
||||||
|
> Sesuaikan `AUTH_ORIGIN` dan `POST_LOGOUT_REDIRECT_URI` dengan alamat/domain server Anda (misal: `http://10.10.150.175:3000`).
|
||||||
|
|
||||||
|
### 2. Bangun dan Jalankan Container
|
||||||
|
Jalankan perintah berikut di terminal untuk melakukan build image sekaligus menjalankan container di background:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Akses Aplikasi
|
||||||
|
Setelah container berhasil berjalan, aplikasi dapat diakses di browser melalui:
|
||||||
|
- **http://localhost:3000** (atau IP server Anda, misal: **http://10.10.150.175:3000**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Perintah Docker yang Sering Digunakan (Useful Commands)
|
||||||
|
|
||||||
|
### Menjalankan Container (Jalankan di background)
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mematikan Container
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Membangun Ulang & Menjalankan Ulang (Jika ada perubahan kode)
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Melihat Log Aplikasi secara Real-time
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f web-antrean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Masuk ke Terminal di Dalam Container
|
||||||
|
```bash
|
||||||
|
docker exec -it web-antrean sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Persistensi Database (SQLite)
|
||||||
|
|
||||||
|
Aplikasi `web-antrean` menggunakan `better-sqlite3` untuk menyimpan data sinkronisasi pengguna di `/app/data/users.db`.
|
||||||
|
|
||||||
|
Agar data ini tidak hilang saat container dihapus atau dibangun ulang, kami menggunakan **Volume Mounting** di `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
```
|
||||||
|
Ini akan otomatis memetakan folder local `./data` di host server Anda ke folder `/app/data` di dalam container. File database Anda akan tersimpan dengan aman di folder local `./data`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting (Penyelesaian Masalah)
|
||||||
|
|
||||||
|
### 1. Masalah dengan Native Module compilation (`better-sqlite3`)
|
||||||
|
Jika image gagal dibangun karena error kompilasi C/C++, Dockerfile kami telah dikonfigurasi menggunakan **multi-stage build** dengan base image `node:20-alpine` dan menginstal package compiler (`make`, `g++`, `gcc`, `python3`) di builder stage. Ini menjamin proses build aman dan hasil image final tetap berukuran kecil (~150MB - 200MB).
|
||||||
|
|
||||||
|
### 2. Cek Status Container dan Health Check
|
||||||
|
Container ini dilengkapi dengan health check otomatis. Anda bisa mengecek statusnya dengan:
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
Jika statusnya `Up (healthy)`, berarti server Nuxt Anda telah siap dan dapat diakses.
|
||||||
|
|
||||||
|
### 3. Hapus Cache Docker jika Mengalami Error Aneh
|
||||||
|
```bash
|
||||||
|
docker-compose down --rmi all --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Catatan Keamanan
|
||||||
|
- Jangan pernah meng-commit file `.env` ke Git repository.
|
||||||
|
- Selalu gunakan `NUXT_AUTH_SECRET` yang aman dan unik untuk setiap environment (Development/Production).
|
||||||
@@ -1,7 +1,72 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
nuxtApp.hook('page:start', () => {
|
||||||
|
loading.value = true;
|
||||||
|
});
|
||||||
|
nuxtApp.hook('page:finish', () => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
|
||||||
|
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
|
||||||
|
const STORE_SCHEMA_VERSION = 3;
|
||||||
|
const VERSION_KEY = 'app-store-version';
|
||||||
|
|
||||||
|
// Daftar semua localStorage key yang dikelola oleh Pinia stores
|
||||||
|
const PINIA_STORE_KEYS = [
|
||||||
|
'queue-store-state',
|
||||||
|
'clinic-store-state',
|
||||||
|
'doctor-store',
|
||||||
|
'loket-store-state',
|
||||||
|
'ruang-store-state',
|
||||||
|
'klinikruang-store-state',
|
||||||
|
'antrean-masuk-screen-store',
|
||||||
|
'anjungan-store',
|
||||||
|
'screen-store',
|
||||||
|
'penunjang-store',
|
||||||
|
];
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||||
|
|
||||||
|
const savedVersion = parseInt(localStorage.getItem(VERSION_KEY) || '0', 10);
|
||||||
|
|
||||||
|
if (savedVersion < STORE_SCHEMA_VERSION) {
|
||||||
|
// Versi lama terdeteksi → hapus semua cache Pinia sekaligus
|
||||||
|
console.log(`[Migration] Store schema v${savedVersion} → v${STORE_SCHEMA_VERSION}. Clearing all caches...`);
|
||||||
|
PINIA_STORE_KEYS.forEach(key => localStorage.removeItem(key));
|
||||||
|
localStorage.setItem(VERSION_KEY, String(STORE_SCHEMA_VERSION));
|
||||||
|
console.log('[Migration] Done. App will fetch fresh data from API.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore storage errors (e.g., private browsing mode)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout>
|
<v-app>
|
||||||
<v-app>
|
<v-overlay
|
||||||
|
:model-value="loading"
|
||||||
|
class="align-center justify-center"
|
||||||
|
persistent
|
||||||
|
z-index="9999"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
color="primary"
|
||||||
|
indeterminate
|
||||||
|
size="64"
|
||||||
|
width="6"
|
||||||
|
></v-progress-circular>
|
||||||
|
</v-overlay>
|
||||||
|
|
||||||
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</v-app>
|
</NuxtLayout>
|
||||||
</NuxtLayout>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
:root {
|
||||||
|
/* Neutral Colors */
|
||||||
|
--neutral-900: #212121;
|
||||||
|
--neutral-800: #4D4D4D;
|
||||||
|
--neutral-700: #717171;
|
||||||
|
--neutral-600: #89939E;
|
||||||
|
--neutral-500: #CDD4DC;
|
||||||
|
--neutral-400: #E1E5EA;
|
||||||
|
--neutral-300: #F5F7FA;
|
||||||
|
--neutral-100: #FFFFFF;
|
||||||
|
|
||||||
|
/* Primary Colors (Blue) */
|
||||||
|
--primary-700: #3556AE;
|
||||||
|
--primary-600: #3A61C9;
|
||||||
|
--primary-500: #567EE7;
|
||||||
|
--primary-400: #7898EC;
|
||||||
|
--primary-300: #9AB2F1;
|
||||||
|
--primary-200: #BBCBF5;
|
||||||
|
--primary-100: #DDE5FA;
|
||||||
|
|
||||||
|
/* Secondary Colors (Orange) */
|
||||||
|
--secondary-700: #E65A0D;
|
||||||
|
--secondary-600: #F16F29;
|
||||||
|
--secondary-500: #FF8441;
|
||||||
|
--secondary-400: #FF9D67;
|
||||||
|
--secondary-300: #FFB58D;
|
||||||
|
--secondary-200: #FFCEB3;
|
||||||
|
--secondary-100: #FFE6D9;
|
||||||
|
|
||||||
|
/* Success Colors (Green) */
|
||||||
|
--success-700: #008D65;
|
||||||
|
--success-600: #33A484;
|
||||||
|
--success-500: #59B59B;
|
||||||
|
--success-400: #80C6B2;
|
||||||
|
--success-300: #A6D7C9;
|
||||||
|
--success-200: #CCE8E0;
|
||||||
|
--success-100: #E5F4F0;
|
||||||
|
|
||||||
|
/* Danger Colors (Red) */
|
||||||
|
--danger-700: #D82719;
|
||||||
|
--danger-600: #DE473B;
|
||||||
|
--danger-500: #E57269;
|
||||||
|
--danger-400: #EB938C;
|
||||||
|
--danger-300: #F1B3AE;
|
||||||
|
--danger-200: #F7D4D1;
|
||||||
|
--danger-100: #FBE9E8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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-secondary-100 { background-color: var(--secondary-100); }
|
||||||
|
|
||||||
|
.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-success-100 { background-color: var(--success-100); }
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
.bg-danger-100 { background-color: var(--danger-100); }
|
||||||
|
|
||||||
|
/* 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-primary-300 { color: var(--primary-300); }
|
||||||
|
.text-primary-200 { color: var(--primary-200); }
|
||||||
|
.text-primary-100 { color: var(--primary-100); }
|
||||||
|
|
||||||
|
.text-secondary-700 { color: var(--secondary-700); }
|
||||||
|
.text-secondary-600 { color: var(--secondary-600); }
|
||||||
|
.text-secondary-500 { color: var(--secondary-500); }
|
||||||
|
.text-secondary-400 { color: var(--secondary-400); }
|
||||||
|
.text-secondary-300 { color: var(--secondary-300); }
|
||||||
|
.text-secondary-200 { color: var(--secondary-200); }
|
||||||
|
.text-secondary-100 { color: var(--secondary-100); }
|
||||||
|
|
||||||
|
.text-success-700 { color: var(--success-700); }
|
||||||
|
.text-success-600 { color: var(--success-600); }
|
||||||
|
.text-success-500 { color: var(--success-500); }
|
||||||
|
.text-success-400 { color: var(--success-400); }
|
||||||
|
.text-success-300 { color: var(--success-300); }
|
||||||
|
.text-success-200 { color: var(--success-200); }
|
||||||
|
.text-success-100 { color: var(--success-100); }
|
||||||
|
|
||||||
|
.text-danger-700 { color: var(--danger-700); }
|
||||||
|
.text-danger-600 { color: var(--danger-600); }
|
||||||
|
.text-danger-500 { color: var(--danger-500); }
|
||||||
|
.text-danger-400 { color: var(--danger-400); }
|
||||||
|
.text-danger-300 { color: var(--danger-300); }
|
||||||
|
.text-danger-200 { color: var(--danger-200); }
|
||||||
|
.text-danger-100 { color: var(--danger-100); }
|
||||||
|
|
||||||
|
/* Utility Classes untuk Border */
|
||||||
|
.border-primary-500 { border-color: var(--primary-500); }
|
||||||
|
.border-secondary-500 { border-color: var(--secondary-500); }
|
||||||
|
.border-success-500 { border-color: var(--success-500); }
|
||||||
|
.border-danger-500 { border-color: var(--danger-500); }
|
||||||
|
.border-neutral-500 { border-color: var(--neutral-500); }
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/* Base Font */
|
||||||
|
* {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headlines - Desktop */
|
||||||
|
.headline-1 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 64px;
|
||||||
|
line-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-2 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-3 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-4 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body - Desktop */
|
||||||
|
.body-1 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400; /* Regular */
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-2 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400; /* Regular */
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-3 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400; /* Regular */
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Caption - Desktop */
|
||||||
|
.text-1 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400; /* Regular */
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2 {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400; /* Regular */
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Typography */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.headline-1 {
|
||||||
|
font-weight: 700; /* Bold */
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-2 {
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-3 {
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-4 {
|
||||||
|
font-weight: 600; /* Semi Bold */
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
font-weight: 500; /* Medium */
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
font-weight: 400; /* Regular */
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Avatar Images
|
||||||
|
|
||||||
|
## Default Avatar
|
||||||
|
Simpan gambar avatar default dengan nama: `default-avatar.png`
|
||||||
|
|
||||||
|
Gambar avatar default akan digunakan ketika user tidak memiliki foto profile.
|
||||||
|
|
||||||
|
## Cara Menggunakan
|
||||||
|
1. Simpan gambar avatar yang ingin digunakan dengan nama `default-avatar.png`
|
||||||
|
2. Pastikan gambar dalam format PNG dengan transparansi (jika diperlukan)
|
||||||
|
3. Ukuran gambar disarankan minimal 300x300px untuk kualitas yang baik
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,190 @@
|
|||||||
|
@use 'sass:map';
|
||||||
|
|
||||||
|
$neutral-900: #212121;
|
||||||
|
$neutral-800: #4D4D4D;
|
||||||
|
$neutral-700: #717171;
|
||||||
|
$neutral-600: #89939E;
|
||||||
|
$neutral-500: #CDD4DC;
|
||||||
|
$neutral-400: #E1E5EA;
|
||||||
|
$neutral-300: #F5F7FA;
|
||||||
|
$neutral-100: #FFFFFF;
|
||||||
|
|
||||||
|
$primary-700: #3556AE;
|
||||||
|
$primary-600: #3A61C9;
|
||||||
|
$primary-500: #567EE7;
|
||||||
|
$primary-400: #7898EC;
|
||||||
|
$primary-300: #9AB2F1;
|
||||||
|
$primary-200: #BBCBF5;
|
||||||
|
$primary-100: #DDE5FA;
|
||||||
|
$primary-50: #EFF3FD;
|
||||||
|
|
||||||
|
$secondary-700: #E65A0D;
|
||||||
|
$secondary-600: #F16F29;
|
||||||
|
$secondary-500: #FF8441;
|
||||||
|
$secondary-400: #FF9D67;
|
||||||
|
$secondary-300: #FFB58D;
|
||||||
|
$secondary-200: #FFCEB3;
|
||||||
|
$secondary-100: #FFE6D9;
|
||||||
|
$secondary-50: #FFF3EC;
|
||||||
|
|
||||||
|
$success-700: #008D65;
|
||||||
|
$success-600: #33A484;
|
||||||
|
$success-500: #59B59B;
|
||||||
|
$success-400: #80C6B2;
|
||||||
|
$success-300: #A6D7C9;
|
||||||
|
$success-200: #CCE8E0;
|
||||||
|
$success-100: #E5F4F0;
|
||||||
|
|
||||||
|
$danger-700: #D82719;
|
||||||
|
$danger-600: #DE473B;
|
||||||
|
$danger-500: #E57269;
|
||||||
|
$danger-400: #EB938C;
|
||||||
|
$danger-300: #F1B3AE;
|
||||||
|
$danger-200: #F7D4D1;
|
||||||
|
$danger-100: #FBE9E8;
|
||||||
|
|
||||||
|
// 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};
|
||||||
|
--color-primary-50: #{$primary-50};
|
||||||
|
|
||||||
|
// 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};
|
||||||
|
--color-secondary-100: #{$secondary-100};
|
||||||
|
--color-secondary-50: #{$secondary-50};
|
||||||
|
|
||||||
|
// 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};
|
||||||
|
--color-success-100: #{$success-100};
|
||||||
|
|
||||||
|
// 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};
|
||||||
|
--color-danger-100: #{$danger-100};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
'primary-100': $primary-100,
|
||||||
|
'primary-50': $primary-50,
|
||||||
|
'secondary-700': $secondary-700,
|
||||||
|
'secondary-600': $secondary-600,
|
||||||
|
'secondary-500': $secondary-500,
|
||||||
|
'secondary-400': $secondary-400,
|
||||||
|
'secondary-300': $secondary-300,
|
||||||
|
'secondary-200': $secondary-200,
|
||||||
|
'secondary-100': $secondary-100,
|
||||||
|
'secondary-50': $secondary-50,
|
||||||
|
'success-700': $success-700,
|
||||||
|
'success-600': $success-600,
|
||||||
|
'success-500': $success-500,
|
||||||
|
'success-400': $success-400,
|
||||||
|
'success-300': $success-300,
|
||||||
|
'success-200': $success-200,
|
||||||
|
'success-100': $success-100,
|
||||||
|
'danger-700': $danger-700,
|
||||||
|
'danger-600': $danger-600,
|
||||||
|
'danger-500': $danger-500,
|
||||||
|
'danger-400': $danger-400,
|
||||||
|
'danger-300': $danger-300,
|
||||||
|
'danger-200': $danger-200,
|
||||||
|
'danger-100': $danger-100
|
||||||
|
);
|
||||||
|
|
||||||
|
@each $name, $color in $colors {
|
||||||
|
@include color-utilities($name, $color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorthand semantic colors
|
||||||
|
.text-primary { color: $primary-500; }
|
||||||
|
.text-secondary { color: $secondary-500; }
|
||||||
|
.text-success { color: $success-600; }
|
||||||
|
.text-danger { color: $danger-600; }
|
||||||
|
.text-muted { color: $neutral-600; }
|
||||||
|
|
||||||
|
.bg-primary { background-color: $primary-500; }
|
||||||
|
.bg-secondary { background-color: $secondary-500; }
|
||||||
|
.bg-success { background-color: $success-600; }
|
||||||
|
.bg-danger { background-color: $danger-600; }
|
||||||
|
.bg-light { background-color: $neutral-300; }
|
||||||
|
.bg-white { background-color: $neutral-100; }
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@use 'sass:map';
|
||||||
|
@use './variables' as *;
|
||||||
|
|
||||||
|
// Mixin untuk generate typography classes
|
||||||
|
@mixin typography($name, $size, $line-height, $weight) {
|
||||||
|
.#{$name} {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
font-size: $size;
|
||||||
|
line-height: $line-height;
|
||||||
|
font-weight: $weight;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base typography
|
||||||
|
* {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate desktop typography classes - Gunakan map.get
|
||||||
|
@each $name, $props in $font-sizes {
|
||||||
|
@include typography(
|
||||||
|
$name,
|
||||||
|
map.get($props, 'size'),
|
||||||
|
map.get($props, 'line-height'),
|
||||||
|
map.get($props, 'weight')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mobile typography classes - Gunakan map.get
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
@each $name, $props in $font-sizes-mobile {
|
||||||
|
@include typography(
|
||||||
|
$name,
|
||||||
|
map.get($props, 'size'),
|
||||||
|
map.get($props, 'line-height'),
|
||||||
|
map.get($props, 'weight')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML semantic tags
|
||||||
|
h1 {
|
||||||
|
@extend .headline-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@extend .headline-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@extend .headline-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
@extend .headline-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
@extend .body-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
@extend .caption-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility classes
|
||||||
|
.text-regular {
|
||||||
|
font-weight: $font-weight-regular !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-medium {
|
||||||
|
font-weight: $font-weight-medium !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-semibold {
|
||||||
|
font-weight: $font-weight-semibold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-bold {
|
||||||
|
font-weight: $font-weight-bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-uppercase {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line clamp utilities
|
||||||
|
@for $i from 1 through 5 {
|
||||||
|
.line-clamp-#{$i} {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: $i;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@use 'sass:map';
|
||||||
|
@use 'sass:math';
|
||||||
|
|
||||||
|
// Font Family
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
|
||||||
|
// Font Weights
|
||||||
|
$font-weight-regular: 400;
|
||||||
|
$font-weight-medium: 500;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
$font-weight-bold: 700;
|
||||||
|
|
||||||
|
// Font Sizes & Line Heights - Desktop
|
||||||
|
$font-sizes: (
|
||||||
|
'headline-1': (
|
||||||
|
'size': 64px,
|
||||||
|
'line-height': 76px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'headline-2': (
|
||||||
|
'size': 36px,
|
||||||
|
'line-height': 44px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'headline-3': (
|
||||||
|
'size': 28px,
|
||||||
|
'line-height': 36px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'headline-4': (
|
||||||
|
'size': 20px,
|
||||||
|
'line-height': 28px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'body-1': (
|
||||||
|
'size': 18px,
|
||||||
|
'line-height': 28px,
|
||||||
|
'weight': $font-weight-regular
|
||||||
|
),
|
||||||
|
'body-2': (
|
||||||
|
'size': 16px,
|
||||||
|
'line-height': 24px,
|
||||||
|
'weight': $font-weight-regular
|
||||||
|
),
|
||||||
|
'body-3': (
|
||||||
|
'size': 14px,
|
||||||
|
'line-height': 20px,
|
||||||
|
'weight': $font-weight-regular
|
||||||
|
),
|
||||||
|
'caption-1': (
|
||||||
|
'size': 16px,
|
||||||
|
'line-height': 24px,
|
||||||
|
'weight': $font-weight-regular
|
||||||
|
),
|
||||||
|
'caption-2': (
|
||||||
|
'size': 12px,
|
||||||
|
'line-height': 16px,
|
||||||
|
'weight': $font-weight-regular
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Font Sizes - Mobile
|
||||||
|
$font-sizes-mobile: (
|
||||||
|
'headline-1': (
|
||||||
|
'size': 28px,
|
||||||
|
'line-height': 36px,
|
||||||
|
'weight': $font-weight-bold
|
||||||
|
),
|
||||||
|
'headline-2': (
|
||||||
|
'size': 20px,
|
||||||
|
'line-height': 28px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'headline-3': (
|
||||||
|
'size': 18px,
|
||||||
|
'line-height': 24px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'headline-4': (
|
||||||
|
'size': 18px,
|
||||||
|
'line-height': 24px,
|
||||||
|
'weight': $font-weight-semibold
|
||||||
|
),
|
||||||
|
'body': (
|
||||||
|
'size': 16px,
|
||||||
|
'line-height': 24px,
|
||||||
|
'weight': $font-weight-medium
|
||||||
|
),
|
||||||
|
'caption': (
|
||||||
|
'size': 14px,
|
||||||
|
'line-height': 20px,
|
||||||
|
'weight': $font-weight-regular
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Breakpoints
|
||||||
|
$breakpoint-mobile: 768px;
|
||||||
|
$breakpoint-tablet: 1024px;
|
||||||
|
$breakpoint-desktop: 1280px;
|
||||||
|
|
||||||
|
// Mixins
|
||||||
|
@mixin heading($level) {
|
||||||
|
@if map.has-key($font-sizes, $level) {
|
||||||
|
$props: map.get($font-sizes, $level);
|
||||||
|
font-size: map.get($props, 'size');
|
||||||
|
line-height: map.get($props, 'line-height');
|
||||||
|
font-weight: map.get($props, 'weight');
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin responsive-text {
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
// Check-in Page Component Styles
|
||||||
|
@import 'variables';
|
||||||
|
|
||||||
|
/* Modern Minimalist Background */
|
||||||
|
.bg-modern {
|
||||||
|
background: $bg-white;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scroll-container {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scroll-container::-webkit-scrollbar {
|
||||||
|
width: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scroll-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-overflow {
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-modern::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(21, 101, 192, 0.05) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(21, 101, 192, 0.05) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Card dengan Glassmorphism */
|
||||||
|
.main-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: $radius-xl !important;
|
||||||
|
box-shadow: $shadow-xl !important;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
z-index: $z-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Modern Minimalis */
|
||||||
|
.header-modern {
|
||||||
|
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
|
||||||
|
padding: $spacing-xl $spacing-2xl $spacing-lg;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-modern::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-circle {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-modern {
|
||||||
|
font-size: $font-3xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-modern {
|
||||||
|
font-size: $font-base;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern {
|
||||||
|
position: relative;
|
||||||
|
z-index: $z-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-tab) {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
min-width: 120px;
|
||||||
|
transition: all $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-tab:hover) {
|
||||||
|
color: rgba(255, 255, 255, 1) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-tab--selected) {
|
||||||
|
color: white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-slider) {
|
||||||
|
background-color: $secondary-color !important;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-modern {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-modern .v-icon {
|
||||||
|
font-size: $font-xl !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.content-modern {
|
||||||
|
background: white;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-modern::-webkit-scrollbar {
|
||||||
|
width: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-modern::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-modern::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
animation: fadeIn $transition-slow ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Header */
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
border: 1px solid rgba(21, 101, 192, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-wrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 $spacing-xs;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-subtitle {
|
||||||
|
font-size: $font-base;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Scanner Modern */
|
||||||
|
.qr-scanner-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: $spacing-2xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px dashed $primary-color;
|
||||||
|
box-shadow: 0 4px 20px rgba(21, 101, 192, 0.1);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
background: #000;
|
||||||
|
position: relative;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 500px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid rgba(21, 101, 192, 0.2);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(video) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: block !important;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #000;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(canvas) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(#qr-reader__dashboard) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(#qr-reader__scan_region) {
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 3px solid rgba(21, 101, 192, 0.8) !important;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(#qr-reader__scan_region::before) {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
left: -3px;
|
||||||
|
right: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
border: 3px solid rgba(21, 101, 192, 0.8);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
animation: pulse-border 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(#qr-reader__scan_region video) {
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: $font-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-instruction {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
padding: 10px $spacing-md;
|
||||||
|
background: linear-gradient(135deg, rgba(21, 101, 192, 0.1) 0%, rgba(13, 71, 161, 0.1) 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: $primary-color;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: $font-sm;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(21, 101, 192, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
z-index: $z-overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-overlay {
|
||||||
|
position: absolute;
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-tl {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: $radius-sm 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-tr {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 0 $radius-sm 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-bl {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 0 $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-br {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 $radius-sm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-line {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, $primary-color, transparent);
|
||||||
|
top: 0;
|
||||||
|
animation: scan 2s linear infinite;
|
||||||
|
box-shadow: 0 0 10px $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan {
|
||||||
|
0% { top: 0; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-icon {
|
||||||
|
position: relative;
|
||||||
|
z-index: $z-base;
|
||||||
|
animation: breathe 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Buttons */
|
||||||
|
.btn-primary-modern {
|
||||||
|
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%) !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
border-radius: $radius-md !important;
|
||||||
|
padding: 14px $spacing-2xl !important;
|
||||||
|
transition: all $transition-bezier;
|
||||||
|
box-shadow: $shadow-primary !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-modern:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-primary-hover !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-modern:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-modern:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop-modern {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
border-radius: $radius-md !important;
|
||||||
|
padding: 14px $spacing-2xl !important;
|
||||||
|
transition: all $transition-bezier;
|
||||||
|
box-shadow: $shadow-error !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop-modern:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-error-hover !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
margin-top: $spacing-xl;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-centered {
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 240px !important;
|
||||||
|
max-width: 320px !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-centered-small {
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 180px !important;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-test-camera {
|
||||||
|
border-color: $primary-color !important;
|
||||||
|
color: $primary-color !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-test-camera :deep(.v-btn__content) {
|
||||||
|
color: $primary-color !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-test-camera :deep(.v-icon) {
|
||||||
|
color: $primary-color !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Inputs */
|
||||||
|
.input-modern :deep(.v-field) {
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: $font-md;
|
||||||
|
background: $bg-light;
|
||||||
|
border: 1.5px solid $border-light;
|
||||||
|
transition: all $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-modern :deep(.v-field--focused) {
|
||||||
|
background: white;
|
||||||
|
border-color: $primary-color;
|
||||||
|
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-modern :deep(.v-field__input) {
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
padding-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-modern :deep(.v-label) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
padding-top: $spacing-lg;
|
||||||
|
border-top: 1px solid $border-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-alert-centered {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-alert-centered :deep(.v-alert__content) {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .v-btn {
|
||||||
|
transition: all $transition-base;
|
||||||
|
border-radius: $radius-md !important;
|
||||||
|
border: 1.5px solid $border-light !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .v-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: $primary-color !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(21, 101, 192, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .v-btn :deep(.v-icon) {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .v-btn[color="primary"] {
|
||||||
|
color: $primary-color !important;
|
||||||
|
border-color: $primary-color !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .v-btn[color="primary"] :deep(.v-icon) {
|
||||||
|
color: $primary-color !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
background: white;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container :deep(canvas) {
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-display {
|
||||||
|
animation: slideUp 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Footer Modern */
|
||||||
|
.stats-footer-modern {
|
||||||
|
animation: slideUp 0.5s ease-out 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern {
|
||||||
|
background: white;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-lg $spacing-md;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid $border-light;
|
||||||
|
transition: all $transition-bezier;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, $primary-color 0%, $primary-dark 100%);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
border-color: $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-modern {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
background: $bg-blue-light;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-snackbar {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
margin-right: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-snackbar :deep(.v-snackbar__wrapper) {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History Dialog Styles */
|
||||||
|
.history-list {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
transition: all $transition-base;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-success {
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
background: rgba(76, 175, 80, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-failed {
|
||||||
|
border-left-color: #f44336;
|
||||||
|
background: rgba(244, 67, 54, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pending {
|
||||||
|
border-left-color: #ff9800;
|
||||||
|
background: rgba(255, 152, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Optimization */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.v-container.no-scroll-container {
|
||||||
|
padding: $spacing-sm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-card {
|
||||||
|
border-radius: $radius-lg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-modern {
|
||||||
|
padding: $spacing-lg $spacing-lg $spacing-md !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-modern {
|
||||||
|
padding: $spacing-lg !important;
|
||||||
|
max-height: calc(100vh - 180px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-circle {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-modern {
|
||||||
|
font-size: $font-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-modern {
|
||||||
|
font-size: $font-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-modern {
|
||||||
|
padding: $spacing-xl $spacing-lg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
padding: $spacing-lg;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-wrapper {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-subtitle {
|
||||||
|
font-size: $font-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper {
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 70vh;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-reader-wrapper :deep(video) {
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-instruction {
|
||||||
|
font-size: $font-sm;
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-footer-modern {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern {
|
||||||
|
padding: $spacing-md $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: $font-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: $font-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-modern {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Check-in Page Dialog Styles
|
||||||
|
@import 'variables';
|
||||||
|
|
||||||
|
.blur-dialog :deep(.v-overlay__scrim) {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
overflow: hidden;
|
||||||
|
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8) translateY(20px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%);
|
||||||
|
animation: rotate 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
animation: bounceIn 0.6s ease-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounceIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-icon {
|
||||||
|
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-header {
|
||||||
|
background: linear-gradient(135deg, $success-color 0%, $success-dark 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header {
|
||||||
|
background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header {
|
||||||
|
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bg-error {
|
||||||
|
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-button {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Patient Info Card Styling */
|
||||||
|
.patient-info-card {
|
||||||
|
background: rgba(250, 250, 250, 0.8) !important;
|
||||||
|
border-color: $border-grey !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-info-label {
|
||||||
|
color: $text-grey !important;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-info-value {
|
||||||
|
color: $text-grey-dark !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-info-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative Circles */
|
||||||
|
.decorative-circles {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-1 {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
top: -50px;
|
||||||
|
left: -50px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-2 {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
top: 50%;
|
||||||
|
right: -75px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-3 {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
bottom: -40px;
|
||||||
|
left: 20%;
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(20px, -20px) scale(1.1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Container */
|
||||||
|
.message-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Instruction Box */
|
||||||
|
.instruction-box {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-success {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
border-color: rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-warning {
|
||||||
|
background: rgba(255, 152, 0, 0.1);
|
||||||
|
border-color: rgba(255, 152, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon Background */
|
||||||
|
.icon-bg {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bg-success {
|
||||||
|
background: linear-gradient(135deg, $success-color 0%, $success-dark 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bg-warning {
|
||||||
|
background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bg-error {
|
||||||
|
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Check-in Page Variables
|
||||||
|
// Colors
|
||||||
|
$primary-color: #1565C0;
|
||||||
|
$primary-dark: #0D47A1;
|
||||||
|
$secondary-color: #FB8C00;
|
||||||
|
$success-color: #66BB6A;
|
||||||
|
$success-dark: #43A047;
|
||||||
|
$warning-color: #FFA726;
|
||||||
|
$warning-dark: #FB8C00;
|
||||||
|
$error-color: #EF5350;
|
||||||
|
$error-dark: #E53935;
|
||||||
|
|
||||||
|
// Background Colors
|
||||||
|
$bg-white: #ffffff;
|
||||||
|
$bg-light: #fafafa;
|
||||||
|
$bg-grey: #f5f7fa;
|
||||||
|
$bg-blue-light: #e3f2fd;
|
||||||
|
|
||||||
|
// Text Colors
|
||||||
|
$text-primary: #1a1a1a;
|
||||||
|
$text-secondary: #6b7280;
|
||||||
|
$text-grey: #9e9e9e;
|
||||||
|
$text-grey-dark: #757575;
|
||||||
|
|
||||||
|
// Border Colors
|
||||||
|
$border-light: #e5e7eb;
|
||||||
|
$border-grey: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-primary: 0 4px 16px rgba(21, 101, 192, 0.3);
|
||||||
|
$shadow-primary-hover: 0 8px 24px rgba(21, 101, 192, 0.4);
|
||||||
|
$shadow-error: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||||
|
$shadow-error-hover: 0 8px 24px rgba(239, 68, 68, 0.4);
|
||||||
|
|
||||||
|
// Border Radius
|
||||||
|
$radius-sm: 8px;
|
||||||
|
$radius-md: 12px;
|
||||||
|
$radius-lg: 16px;
|
||||||
|
$radius-xl: 24px;
|
||||||
|
$radius-full: 50%;
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
$spacing-xs: 4px;
|
||||||
|
$spacing-sm: 8px;
|
||||||
|
$spacing-md: 12px;
|
||||||
|
$spacing-lg: 16px;
|
||||||
|
$spacing-xl: 20px;
|
||||||
|
$spacing-2xl: 24px;
|
||||||
|
$spacing-3xl: 28px;
|
||||||
|
|
||||||
|
// Font Sizes
|
||||||
|
$font-xs: 10px;
|
||||||
|
$font-sm: 11px;
|
||||||
|
$font-base: 13px;
|
||||||
|
$font-md: 15px;
|
||||||
|
$font-lg: 16px;
|
||||||
|
$font-xl: 18px;
|
||||||
|
$font-2xl: 20px;
|
||||||
|
$font-3xl: 24px;
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
$transition-fast: 0.2s ease;
|
||||||
|
$transition-base: 0.3s ease;
|
||||||
|
$transition-slow: 0.4s ease;
|
||||||
|
$transition-bezier: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
// Z-index
|
||||||
|
$z-base: 1;
|
||||||
|
$z-overlay: 10;
|
||||||
|
$z-dialog: 100;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// variables for Grand Paviliun (Eksekutif)
|
||||||
|
.theme-grand-paviliun {
|
||||||
|
--gp-primary: #3F51B5; // Indigo header
|
||||||
|
--gp-primary-dark: #303F9F;
|
||||||
|
--gp-bg: #F5F7FA; // Light grayish blue background
|
||||||
|
--gp-surface: #FFFFFF;
|
||||||
|
--gp-text-primary: #1F2937;
|
||||||
|
--gp-text-secondary: #6B7280;
|
||||||
|
--gp-border: #E5E7EB;
|
||||||
|
|
||||||
|
--gp-success: #10B981; // Selesai
|
||||||
|
--gp-danger: #EF4444; // Pending
|
||||||
|
--gp-warning: #F59E0B;
|
||||||
|
--gp-info: #3B82F6;
|
||||||
|
|
||||||
|
// Custom colors for specific tags
|
||||||
|
--gp-room-number: #F97316; // Orange text for RUANG 02
|
||||||
|
|
||||||
|
background-color: var(--gp-bg);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: 'Inter', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility classes
|
||||||
|
.gp-text-primary { color: var(--gp-text-primary); }
|
||||||
|
.gp-text-secondary { color: var(--gp-text-secondary); }
|
||||||
|
.gp-font-bold { font-weight: 700; }
|
||||||
|
.gp-font-semibold { font-weight: 600; }
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Gunakan @use daripada @import
|
||||||
|
@use "./variables" as *;
|
||||||
|
@use "./colors" as *;
|
||||||
|
@use "./typography" as *;
|
||||||
|
@use "./pages/dashboard" as *;
|
||||||
|
|
||||||
|
// Global styles
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-textPrimary);
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease,
|
||||||
|
color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection color
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-primary-300);
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus styles
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary-500);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive images
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links
|
||||||
|
a {
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons base
|
||||||
|
button {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
@use "../variables" as *;
|
||||||
|
@use "../colors" as *;
|
||||||
|
|
||||||
|
.dashboard-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-body {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.token-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-content {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
max-height: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 30px;
|
||||||
|
background: linear-gradient(transparent, #f8f9fa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-section {
|
||||||
|
.payload-content {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
.dashboard-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/* assets/styles/loginpage/login.scss */
|
||||||
|
@use "~/assets/scss/_colors.scss" as *;
|
||||||
|
|
||||||
|
/* Main Background - Using Primary Color */
|
||||||
|
.login-background {
|
||||||
|
background: $primary-500; /* Primary-500: #567EE7 */
|
||||||
|
height: 100dvh;
|
||||||
|
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 Primary 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 primary 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: $neutral-300 !important; /* Neutral-300: #F5F7FA */
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
/* box-shadow is now managed by .main-card */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Login Section (Neutral-300) */
|
||||||
|
.login-section-box {
|
||||||
|
background: $neutral-300; /* Neutral-300: #F5F7FA */
|
||||||
|
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: $neutral-100 !important;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header - Using Neutral Color with Larger Font */
|
||||||
|
.welcome-title-dark {
|
||||||
|
color: $neutral-900; /* Neutral-900: #212121 */
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-instruction-dark {
|
||||||
|
color: $neutral-700; /* Neutral-700: #717171 */
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Title - Using Primary Color */
|
||||||
|
.app-title-dark {
|
||||||
|
color: $primary-500; /* Primary-500: #567EE7 */
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hospital-logo {
|
||||||
|
height: 180px;
|
||||||
|
width: auto;
|
||||||
|
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sso-text-dark {
|
||||||
|
color: $neutral-800; /* Dark grey for SSO text */
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons (Primary theme) */
|
||||||
|
.login-btn {
|
||||||
|
background: $primary-500 !important; /* Primary-500: #567EE7 */
|
||||||
|
color: $neutral-100 !important; /* White text on primary button */
|
||||||
|
border: none;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 25px rgba(86, 126, 231, 0.3),
|
||||||
|
/* Primary shadow */ 0 4px 12px rgba(86, 126, 231, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: $primary-600 !important; /* Primary-600: #3A61C9 on hover */
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(86, 126, 231, 0.4),
|
||||||
|
0 6px 15px rgba(86, 126, 231, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-btn-dark {
|
||||||
|
color: $primary-500 !important; /* Primary-500: #567EE7 */
|
||||||
|
border: 2px solid $primary-500 !important; /* Primary border */
|
||||||
|
background: transparent !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-transform: none;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-btn-dark:hover {
|
||||||
|
background: rgba(
|
||||||
|
86,
|
||||||
|
126,
|
||||||
|
231,
|
||||||
|
0.05
|
||||||
|
) !important; /* Light primary hover background */
|
||||||
|
border-color: $primary-600 !important; /* Primary-600 border on hover */
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Divider - Dark for white background */
|
||||||
|
.custom-divider-dark {
|
||||||
|
border-color: $neutral-400 !important; /* Light grey divider */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help Text and Links - Dark for white background */
|
||||||
|
.help-text-dark {
|
||||||
|
color: $neutral-800; /* Dark grey help text */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link-dark {
|
||||||
|
color: $primary-500 !important; /* Primary-500: #567EE7 */
|
||||||
|
text-decoration: underline;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link-dark {
|
||||||
|
color: $neutral-700; /* Grey link */
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link-dark:hover {
|
||||||
|
color: $primary-500; /* Primary-500 on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transparent Background */
|
||||||
|
.transparent {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog Accents */
|
||||||
|
.v-card-title .v-icon,
|
||||||
|
.v-list-item .v-icon {
|
||||||
|
color: $primary-500 !important; /* Primary accents */
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn[color="#FF9B1B"] {
|
||||||
|
background-color: $primary-500 !important;
|
||||||
|
color: $neutral-100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-alert[color="orange"] {
|
||||||
|
border-left: 8px solid $primary-500 !important;
|
||||||
|
color: $neutral-900 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design Adjustments for single column */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.main-card {
|
||||||
|
margin: 1rem;
|
||||||
|
max-width: 450px !important; /* Enforce max-width on smaller screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section-box {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 2rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.main-card {
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section-box {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-icon">
|
||||||
|
<v-icon size="28" color="white">mdi-shield-lock-outline</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h2 class="page-title">Manajemen Hak Akses (Keycloak Integration)</h2>
|
||||||
|
<p class="page-subtitle">Konfigurasi Akses Halaman berdasarkan Role & Group Real-time</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hak-akses-page pa-4">
|
||||||
|
<!-- Tabs Selection -->
|
||||||
|
<v-tabs v-model="activeTab" color="primary" class="mb-4 bg-white rounded-lg elevation-1" align-tabs="start">
|
||||||
|
<v-tab value="roles" class="text-capitalize">
|
||||||
|
<v-icon start>mdi-account-star-outline</v-icon>
|
||||||
|
Realm Roles
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="groups" class="text-capitalize">
|
||||||
|
<v-icon start>mdi-account-group-outline</v-icon>
|
||||||
|
Groups
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div class="action-bar mb-4 d-flex justify-space-between align-center px-4 py-3 bg-white rounded-xl border elevation-1">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
:label="`Cari ${activeTab === 'roles' ? 'Role' : 'Group'}...`"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
rounded="lg"
|
||||||
|
class="max-width-400"
|
||||||
|
></v-text-field>
|
||||||
|
<v-chip color="primary" variant="tonal" class="font-weight-bold">
|
||||||
|
{{ displayItems.length }} {{ activeTab === 'roles' ? 'Roles' : 'Groups' }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
@click="loadData"
|
||||||
|
prepend-icon="mdi-sync"
|
||||||
|
variant="flat"
|
||||||
|
rounded="lg"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
Sync Keycloak
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<v-card class="rounded-xl border-0 shadow-soft overflow-hidden">
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="displayItems"
|
||||||
|
:loading="loading"
|
||||||
|
class="custom-table"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<template v-slot:[`item.no`]="{ index }">
|
||||||
|
<span class="text-grey-darken-1 font-weight-medium">{{ index + 1 }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:[`item.name`]="{ item }">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon
|
||||||
|
:icon="activeTab === 'roles' ? 'mdi-account-star' : 'mdi-folder-account'"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
class="mr-2"
|
||||||
|
></v-icon>
|
||||||
|
<span class="font-weight-bold text-primary-darken-1">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:[`item.pagesCount`]="{ item }">
|
||||||
|
<v-tooltip bottom>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="props"
|
||||||
|
:color="item.validPagesCount > 0 ? 'success' : 'grey'"
|
||||||
|
size="small"
|
||||||
|
variant="flat"
|
||||||
|
class="font-weight-bold"
|
||||||
|
>
|
||||||
|
{{ item.validPagesCount }} Halaman
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<span>Akses ke {{ item.validPagesCount }} Halaman Terdaftar</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:[`item.memberCount`]="{ item }">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize font-weight-bold"
|
||||||
|
prepend-icon="mdi-account-multiple-outline"
|
||||||
|
@click="viewMembers(item)"
|
||||||
|
>
|
||||||
|
Deteksi Member
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:[`item.actions`]="{ item }">
|
||||||
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-shield-edit-outline"
|
||||||
|
class="text-capitalize rounded-lg mr-2"
|
||||||
|
@click="editPermissions(item)"
|
||||||
|
>
|
||||||
|
Atur Akses
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Permissions Dialog -->
|
||||||
|
<v-dialog v-model="showEditDialog" max-width="900px" persistent scrollable>
|
||||||
|
<v-card class="rounded-xl overflow-hidden">
|
||||||
|
<v-card-title class="pa-6 border-b d-flex align-center bg-primary text-white">
|
||||||
|
<v-icon icon="mdi-shield-edit" class="mr-3"></v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">Konfigurasi Akses: {{ editedEntity?.name }}</div>
|
||||||
|
<div class="text-caption text-white text-opacity-75">Tentukan halaman yang dapat diakses oleh {{ activeTab }} ini</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn icon="mdi-close" variant="text" color="white" @click="showEditDialog = false"></v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<div class="pa-6 bg-grey-lighten-4">
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="mb-4 rounded-lg"
|
||||||
|
icon="mdi-information-outline"
|
||||||
|
>
|
||||||
|
Jika user memiliki beberapa Role/Group, mereka akan mendapatkan akses gabungan dari semuanya.
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
<div class="pa-6 pt-0">
|
||||||
|
<EditHakAkses
|
||||||
|
v-if="editedEntity"
|
||||||
|
:pages="editedEntity.pages"
|
||||||
|
@update:pages="editedEntity.pages = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-actions class="pa-6 bg-white">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn variant="outlined" rounded="lg" @click="showEditDialog = false" class="px-6 border-primary-lighten-4 text-primary">Batal</v-btn>
|
||||||
|
<v-btn color="primary" rounded="lg" @click="savePermissions" class="px-6 ml-2" :loading="loading">
|
||||||
|
Simpan Perubahan
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Member List Dialog -->
|
||||||
|
<v-dialog v-model="showMembersDialog" max-width="600px" scrollable>
|
||||||
|
<v-card class="rounded-xl">
|
||||||
|
<v-card-title class="pa-6 border-b d-flex align-center bg-grey-lighten-4">
|
||||||
|
<v-icon icon="mdi-account-group" color="primary" class="mr-3"></v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">Member: {{ editedEntity?.name }}</div>
|
||||||
|
<div class="text-caption text-grey-darken-1">Daftar pengguna yang memiliki {{ activeTab }} ini</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="showMembersDialog = false"></v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-list v-if="!memberLoading && currentMembers.length > 0" lines="two" class="pa-0">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(user, i) in currentMembers"
|
||||||
|
:key="user.id"
|
||||||
|
:border="i !== currentMembers.length - 1"
|
||||||
|
class="px-6 py-3"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar color="primary-lighten-5" size="44">
|
||||||
|
<v-icon icon="mdi-account" color="primary"></v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="font-weight-bold text-primary">{{ user.name }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="mt-1">
|
||||||
|
<v-chip size="x-small" variant="outlined" class="mr-2">{{ user.username }}</v-chip>
|
||||||
|
<span class="text-caption text-grey">{{ user.email }}</span>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<div v-else-if="!memberLoading" class="pa-12 text-center">
|
||||||
|
<v-avatar color="grey-lighten-4" size="80" class="mb-4">
|
||||||
|
<v-icon icon="mdi-account-off-outline" color="grey" size="40"></v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<div class="text-grey font-weight-medium">Tidak ada pengguna yang terdeteksi</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="pa-6">
|
||||||
|
<v-skeleton-loader type="list-item-avatar-two-line@5"></v-skeleton-loader>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout" location="top">
|
||||||
|
{{ snackbar.message }}
|
||||||
|
<template v-slot:actions>
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="snackbar.show = false"></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
|
||||||
|
import { useNavItemsStore } from '~/stores/navItems1';
|
||||||
|
import type { HakAkses } from '~/types/setting';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['auth', 'check-page-access']
|
||||||
|
})
|
||||||
|
|
||||||
|
const navItemsStore = useNavItemsStore();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const activeTab = ref('roles');
|
||||||
|
const hakAksesList = ref<HakAkses[]>([]);
|
||||||
|
const keycloakEntities = ref<{ roles: any[], groups: any[] }>({ roles: [], groups: [] });
|
||||||
|
const loading = ref(false);
|
||||||
|
const showEditDialog = ref(false);
|
||||||
|
const showMembersDialog = ref(false);
|
||||||
|
const editedEntity = ref<any>(null);
|
||||||
|
const currentMembers = ref<any[]>([]);
|
||||||
|
const memberLoading = ref(false);
|
||||||
|
|
||||||
|
const search = ref('');
|
||||||
|
const snackbar = ref({
|
||||||
|
show: false,
|
||||||
|
message: '',
|
||||||
|
color: 'success',
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSnackbar = (message: string, color: string = 'success') => {
|
||||||
|
snackbar.value.message = message;
|
||||||
|
snackbar.value.color = color;
|
||||||
|
snackbar.value.show = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
const headers = [
|
||||||
|
{ title: 'No', key: 'no', align: 'center' as const, width: '80px' },
|
||||||
|
{ title: 'Nama', key: 'name', sortable: true },
|
||||||
|
{ title: 'Akses', key: 'pagesCount', align: 'center' as const, width: '150px' },
|
||||||
|
{ title: 'Member', key: 'memberCount', align: 'center' as const, width: '150px' },
|
||||||
|
{ title: 'Aksi', key: 'actions', align: 'center' as const, sortable: false, width: '250px' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load everything
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 1. Load our local mappings
|
||||||
|
const hakAksesRes = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||||
|
if (hakAksesRes && hakAksesRes.success) {
|
||||||
|
hakAksesList.value = hakAksesRes.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load Keycloak entities
|
||||||
|
const entitiesRes = await $fetch<{ success: boolean, data: any }>('/api/hak-akses/entities');
|
||||||
|
if (entitiesRes && entitiesRes.success) {
|
||||||
|
keycloakEntities.value = entitiesRes.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
showSnackbar('Gagal memuat data dari Keycloak', 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed list based on active tab
|
||||||
|
const displayItems = computed(() => {
|
||||||
|
const list = activeTab.value === 'roles' ? keycloakEntities.value.roles : keycloakEntities.value.groups;
|
||||||
|
|
||||||
|
// Get all valid paths from current navigation store
|
||||||
|
const allNavPaths = new Set<string>();
|
||||||
|
const extractPaths = (items: any[]) => {
|
||||||
|
items.forEach((item: any) => {
|
||||||
|
if (item.path) allNavPaths.add(item.path);
|
||||||
|
if (item.children) extractPaths(item.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
extractPaths(navItemsStore.getNavItems);
|
||||||
|
|
||||||
|
return list.map((entity: any) => {
|
||||||
|
// Find existing permission mapping
|
||||||
|
const mapping = hakAksesList.value.find(h => h.namaHakAkses === entity.name);
|
||||||
|
if (!mapping) return { ...entity, pages: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null };
|
||||||
|
|
||||||
|
// Count only valid pages that exist in navigation
|
||||||
|
const validPages = (mapping.pages || []).filter(p => {
|
||||||
|
const path = typeof p === 'string' ? p : (p as any)?.path;
|
||||||
|
return path && allNavPaths.has(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entity,
|
||||||
|
pages: mapping.pages, // Keep original for editing
|
||||||
|
validPagesCount: validPages.length,
|
||||||
|
status: mapping.status,
|
||||||
|
id_mapping: mapping.id
|
||||||
|
};
|
||||||
|
}).filter((item: any) => {
|
||||||
|
if (!search.value) return true;
|
||||||
|
return item.name.toLowerCase().includes(search.value.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const editPermissions = (item: any) => {
|
||||||
|
editedEntity.value = JSON.parse(JSON.stringify(item));
|
||||||
|
showEditDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewMembers = async (item: any) => {
|
||||||
|
editedEntity.value = item;
|
||||||
|
currentMembers.value = [];
|
||||||
|
showMembersDialog.value = true;
|
||||||
|
memberLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ success: boolean, data: any[] }>(`/api/hak-akses/members`, {
|
||||||
|
params: {
|
||||||
|
type: item.type,
|
||||||
|
name: item.name,
|
||||||
|
id: item.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res && res.success) {
|
||||||
|
currentMembers.value = res.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading members:', error);
|
||||||
|
showSnackbar('Gagal memuat member', 'error');
|
||||||
|
} finally {
|
||||||
|
memberLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePermissions = async () => {
|
||||||
|
if (!editedEntity.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: editedEntity.value.id_mapping, // If null, backend create new
|
||||||
|
namaHakAkses: editedEntity.value.name,
|
||||||
|
status: 'aktif',
|
||||||
|
pages: editedEntity.value.pages
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showSnackbar('Hak akses berhasil diperbarui', 'success');
|
||||||
|
await loadData();
|
||||||
|
await navItemsStore.refreshNavItems();
|
||||||
|
showEditDialog.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving:', error);
|
||||||
|
showSnackbar('Gagal menyimpan perubahan', 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// Colors from Design System
|
||||||
|
$primary-700: #3556AE;
|
||||||
|
$primary-600: #3A61C9;
|
||||||
|
|
||||||
|
$success-700: #1B6E53;
|
||||||
|
$success-600: #009262;
|
||||||
|
$neutral-100: #FFFFFF;
|
||||||
|
$neutral-800: #4D4D4D;
|
||||||
|
|
||||||
|
// Font Family & Weights
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
$font-weight-regular: 400;
|
||||||
|
$font-weight-medium: 500;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
|
||||||
|
// Apply font family
|
||||||
|
* {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PAGE HEADER
|
||||||
|
// ============================================
|
||||||
|
.page-header {
|
||||||
|
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 28px;
|
||||||
|
height: 80px;
|
||||||
|
color: $neutral-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-right: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
margin: 0;
|
||||||
|
color: $neutral-100;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
color: $neutral-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ACTION BAR (matching UserLogin)
|
||||||
|
// ============================================
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: $neutral-100;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #E0E0E0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-chip {
|
||||||
|
color: $primary-600 !important;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(th) {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-width-400 {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-b {
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// server/api/hak-akses/entities.get.ts
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log("📥 Fetching Keycloak entities (roles & groups)");
|
||||||
|
|
||||||
|
// Get session from session store
|
||||||
|
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
|
||||||
|
const session = await getSessionFromCookie(event);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Session expired or not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const accessToken = session.accessToken;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "No access token found in session",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issuerUrl = new URL(config.keycloakIssuer);
|
||||||
|
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
|
||||||
|
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
|
||||||
|
|
||||||
|
// 1. Fetch Realm Roles
|
||||||
|
const rolesResponse = await fetch(`${adminBaseUrl}/roles`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rolesResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch roles: ${rolesResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = await rolesResponse.json();
|
||||||
|
// Filter out potential client-specific roles if needed, keeping realm roles
|
||||||
|
const realmRoles = roles.filter((r: any) => !r.clientRole);
|
||||||
|
|
||||||
|
// 2. Fetch Groups
|
||||||
|
const groupsResponse = await fetch(`${adminBaseUrl}/groups`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!groupsResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch groups: ${groupsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await groupsResponse.json();
|
||||||
|
|
||||||
|
// 3. For each role and group, we'll need to fetch members to show the count
|
||||||
|
// NOTE: This might be expensive if there are many roles/groups.
|
||||||
|
// For now, let's just return the list and we'll fetch members on demand in the UI or in a separate task.
|
||||||
|
// To be efficient, we'll only return basic info here.
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
roles: realmRoles.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description || '',
|
||||||
|
type: 'role'
|
||||||
|
})),
|
||||||
|
groups: groups.map((g: any) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
path: g.path,
|
||||||
|
type: 'group'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ Error fetching Keycloak entities:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || "Failed to fetch Keycloak entities",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// server/api/hak-akses/members.get.ts
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event);
|
||||||
|
const { type, name, id } = query;
|
||||||
|
|
||||||
|
if (!type || (!name && !id)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Type and (Name or ID) are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session
|
||||||
|
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
|
||||||
|
const session = await getSessionFromCookie(event);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Session expired",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const accessToken = session.accessToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issuerUrl = new URL(config.keycloakIssuer);
|
||||||
|
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
|
||||||
|
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
|
||||||
|
|
||||||
|
let url = '';
|
||||||
|
if (type === 'role') {
|
||||||
|
url = `${adminBaseUrl}/roles/${name}/users`;
|
||||||
|
} else if (type === 'group') {
|
||||||
|
url = `${adminBaseUrl}/groups/${id}/members`;
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch members: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: members.map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
username: m.username,
|
||||||
|
email: m.email,
|
||||||
|
firstName: m.firstName,
|
||||||
|
lastName: m.lastName,
|
||||||
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.username
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ Error fetching members:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || "Failed to fetch members",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// composables/useHakAkses.ts
|
||||||
|
// Composable for handling user permissions/access based on hakAkses
|
||||||
|
import { useAuth } from "~/composables/useAuth";
|
||||||
|
import type { HakAkses } from "~/types/setting";
|
||||||
|
|
||||||
|
export const useHakAkses = () => {
|
||||||
|
const { user, checkAuth } = useAuth();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pages that user has access to based on their roles
|
||||||
|
*/
|
||||||
|
const getAllowedPages = async (): Promise<string[]> => {
|
||||||
|
// Ensure user is loaded
|
||||||
|
if (!user.value) {
|
||||||
|
await checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = user.value;
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get roles and groups from multiple possible sources in User object
|
||||||
|
const roles = [
|
||||||
|
...(currentUser.roles || []),
|
||||||
|
...((currentUser as any).realm_access?.roles || []),
|
||||||
|
...((currentUser as any).resource_access?.['web-antrean']?.roles || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const groups = (currentUser as any).groups || [];
|
||||||
|
|
||||||
|
// Combine everything the user belongs to
|
||||||
|
const entities = [...new Set([...roles, ...groups])];
|
||||||
|
|
||||||
|
if (entities.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all hak akses data
|
||||||
|
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||||
|
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
const hakAksesList = response.data;
|
||||||
|
|
||||||
|
// Filter hak akses that match user's entities and are active
|
||||||
|
const userHakAkses = hakAksesList.filter((hakAkses) =>
|
||||||
|
entities.includes(hakAkses.namaHakAkses) &&
|
||||||
|
hakAkses.status === 'aktif'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine all pages from all matching hak akses
|
||||||
|
const allPages = userHakAkses.reduce((pages: string[], hakAkses) => {
|
||||||
|
if (hakAkses.pages && Array.isArray(hakAkses.pages)) {
|
||||||
|
return [...pages, ...hakAkses.pages];
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
return [...new Set(allPages)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching allowed pages:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has access to a specific page
|
||||||
|
*/
|
||||||
|
const hasPageAccess = async (pagePath: string): Promise<boolean> => {
|
||||||
|
const allowedPages = await getAllowedPages();
|
||||||
|
return allowedPages.includes(pagePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has access to any page in a list
|
||||||
|
*/
|
||||||
|
const hasAnyPageAccess = async (pagePaths: string[]): Promise<boolean> => {
|
||||||
|
const allowedPages = await getAllowedPages();
|
||||||
|
return pagePaths.some(path => allowedPages.includes(path));
|
||||||
|
};
|
||||||
|
|
||||||
|
const allHakAksesData = ref<HakAkses[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const fetchHakAkses = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||||
|
if (response && response.success) {
|
||||||
|
allHakAksesData.value = response.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching hak akses:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
allHakAksesData,
|
||||||
|
isLoading,
|
||||||
|
fetchHakAkses,
|
||||||
|
getAllowedPages,
|
||||||
|
hasPageAccess,
|
||||||
|
hasAnyPageAccess
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineEventHandler, proxyRequest } from 'h3'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
const targetBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'
|
||||||
|
|
||||||
|
// Extract path suffix after /klinik-api
|
||||||
|
const suffix = event.path.replace(/^\/klinik-api/, '')
|
||||||
|
const targetUrl = `${targetBase}${suffix}`
|
||||||
|
|
||||||
|
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
|
||||||
|
const headers = { ...event.node.req.headers } as Record<string, string>
|
||||||
|
|
||||||
|
// Extract backend target host
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(targetBase)
|
||||||
|
headers.host = urlObj.host
|
||||||
|
} catch (e) {
|
||||||
|
headers.host = config.proxyTargetHostKlinikFallback || '10.10.123.140:8089'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||||
|
const proxyOrigin = config.proxyClientOrigin as string
|
||||||
|
headers.origin = proxyOrigin
|
||||||
|
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||||
|
|
||||||
|
console.log(`[Proxy Klinik-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||||
|
console.log(`[Proxy Klinik-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await proxyRequest(event, targetUrl, {
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Proxy Klinik-API] Error forwarding to ${targetUrl}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineEventHandler, proxyRequest } from 'h3'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
|
||||||
|
|
||||||
|
// Extract path suffix after /stats-api
|
||||||
|
const suffix = event.path.replace(/^\/stats-api/, '')
|
||||||
|
const targetUrl = `${targetBase}${suffix}`
|
||||||
|
|
||||||
|
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
|
||||||
|
const headers = { ...event.node.req.headers } as Record<string, string>
|
||||||
|
|
||||||
|
// Extract backend target host
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(targetBase)
|
||||||
|
headers.host = urlObj.host
|
||||||
|
} catch (e) {
|
||||||
|
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||||
|
const proxyOrigin = config.proxyClientOrigin as string
|
||||||
|
headers.origin = proxyOrigin
|
||||||
|
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||||
|
|
||||||
|
console.log(`[Proxy Stats-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||||
|
console.log(`[Proxy Stats-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await proxyRequest(event, targetUrl, {
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Proxy Stats-API] Error forwarding to ${targetUrl}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineEventHandler, proxyRequest } from 'h3'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
|
||||||
|
|
||||||
|
// Extract path suffix after /visit-api
|
||||||
|
const suffix = event.path.replace(/^\/visit-api/, '')
|
||||||
|
const targetUrl = `${targetBase}${suffix}`
|
||||||
|
|
||||||
|
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
|
||||||
|
const headers = { ...event.node.req.headers } as Record<string, string>
|
||||||
|
|
||||||
|
// Extract backend target host
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(targetBase)
|
||||||
|
headers.host = urlObj.host
|
||||||
|
} catch (e) {
|
||||||
|
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||||
|
const proxyOrigin = config.proxyClientOrigin as string
|
||||||
|
headers.origin = proxyOrigin
|
||||||
|
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||||
|
|
||||||
|
console.log(`[Proxy Visit-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||||
|
console.log(`[Proxy Visit-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await proxyRequest(event, targetUrl, {
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Proxy Visit-API] Error forwarding to ${targetUrl}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-grand-paviliun gp-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="gp-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<v-icon size="24" color="white" class="mr-3">mdi-domain</v-icon>
|
||||||
|
<div>
|
||||||
|
<h1 class="header-title">Klinik Admin Grand Paviliun</h1>
|
||||||
|
<p class="header-date">{{ currentDate }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<v-btn color="#F97316" variant="flat" class="text-white font-weight-bold">
|
||||||
|
<v-icon start size="18">mdi-account-cog</v-icon>
|
||||||
|
KELOLA PASIEN
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="gp-search-bar">
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Cari pasien di semua ruang (barcode, nomor antrian, nama...)"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
variant="outlined"
|
||||||
|
bg-color="white"
|
||||||
|
class="search-input"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="gp-main-content">
|
||||||
|
<div class="kanban-board">
|
||||||
|
<GpRoomColumn
|
||||||
|
v-for="clinic in filteredClinics"
|
||||||
|
:key="clinic.kodeKlinik"
|
||||||
|
:clinic-name="clinic.namaKlinik"
|
||||||
|
:current-patient="clinic.currentPatient"
|
||||||
|
:queue-patients="clinic.queuePatients"
|
||||||
|
:room-options="clinic.roomOptions"
|
||||||
|
@pemeriksaan-awal="(p, r) => handlePemeriksaanAwal(p, clinic.kodeKlinik, r)"
|
||||||
|
@panggil-pemeriksaan="(p, r) => handlePanggilPemeriksaan(p, clinic.kodeKlinik, r)"
|
||||||
|
@action-pending="p => handleAction(p, 'pending', clinic.kodeKlinik)"
|
||||||
|
@action-selesai="p => handleAction(p, 'selesai', clinic.kodeKlinik)"
|
||||||
|
@process-patient="p => handleProcess(p, clinic.kodeKlinik, clinic.roomOptions?.[0]?.nomorRuang || '1')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar -->
|
||||||
|
<GpMonitorSidebar :rooms="monitorRooms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import GpRoomColumn from '../GrandPaviliun/GpRoomColumn.vue';
|
||||||
|
import GpMonitorSidebar from '../GrandPaviliun/GpMonitorSidebar.vue';
|
||||||
|
import { useGrandPaviliun } from '@/composables/useGrandPaviliun';
|
||||||
|
import '@/assets/scss/eksekutif.scss';
|
||||||
|
|
||||||
|
const {
|
||||||
|
clinics,
|
||||||
|
monitorRooms,
|
||||||
|
fetchEksekutifData,
|
||||||
|
handlePemeriksaanAwal,
|
||||||
|
handlePanggilPemeriksaan,
|
||||||
|
processPatientAction
|
||||||
|
} = useGrandPaviliun();
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
const filteredClinics = computed(() => {
|
||||||
|
if (!searchQuery.value) return clinics.value;
|
||||||
|
const lowerSearch = searchQuery.value.toLowerCase();
|
||||||
|
|
||||||
|
// Filter by patient barcode, queue number, or name in the clinic columns
|
||||||
|
return clinics.value.map(clinic => {
|
||||||
|
const isCurrentMatch = clinic.currentPatient && (
|
||||||
|
(clinic.currentPatient.noAntrian || '').toLowerCase().includes(lowerSearch) ||
|
||||||
|
(clinic.currentPatient.barcode || '').toLowerCase().includes(lowerSearch) ||
|
||||||
|
(clinic.currentPatient.name || '').toLowerCase().includes(lowerSearch)
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchedQueue = clinic.queuePatients.filter(p =>
|
||||||
|
(p.noAntrian || '').toLowerCase().includes(lowerSearch) ||
|
||||||
|
(p.barcode || '').toLowerCase().includes(lowerSearch) ||
|
||||||
|
(p.name || '').toLowerCase().includes(lowerSearch)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...clinic,
|
||||||
|
currentPatient: isCurrentMatch ? clinic.currentPatient : null,
|
||||||
|
queuePatients: matchedQueue
|
||||||
|
};
|
||||||
|
}).filter(c => c.currentPatient || c.queuePatients.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action Handlers
|
||||||
|
const handleAction = async (patient, action, kodeKlinik) => {
|
||||||
|
// Use the current room of the patient or fallback to '1'
|
||||||
|
const nomorRuang = patient.nomorRuang || '1';
|
||||||
|
await processPatientAction(patient, action, kodeKlinik, nomorRuang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcess = async (patient, kodeKlinik, fallbackRuang) => {
|
||||||
|
const action = patient.status === 'pending' ? 'waiting' : 'proses';
|
||||||
|
await processPatientAction(patient, action, kodeKlinik, fallbackRuang);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date Formatting
|
||||||
|
const currentDate = computed(() => {
|
||||||
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
return new Date().toLocaleDateString('id-ID', options);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load data when mounted
|
||||||
|
fetchEksekutifData();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--gp-bg, #F5F7FA);
|
||||||
|
}
|
||||||
|
.gp-header {
|
||||||
|
background-color: var(--gp-primary, #3F51B5);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.header-date {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.gp-search-bar {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.gp-main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.kanban-board {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="gp-current-patient">
|
||||||
|
<div class="header-row">
|
||||||
|
<span class="label">SEDANG DIPROSES</span>
|
||||||
|
<div class="badges">
|
||||||
|
<button class="action-badge" @click="$emit('action-pending')"><GpStatusBadge status="PENDING" /></button>
|
||||||
|
<button class="action-badge ml-1" @click="$emit('action-selesai')"><GpStatusBadge status="SELESAI" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="patient-big-info">
|
||||||
|
<div class="queue-number">{{ queueNumber || '-' }}</div>
|
||||||
|
<div class="room-indicator" v-if="roomName">{{ roomName }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<label class="input-label">Tujukan ke ruang*</label>
|
||||||
|
<v-select
|
||||||
|
v-model="selectedRoom"
|
||||||
|
:items="roomOptions"
|
||||||
|
item-title="namaRuang"
|
||||||
|
item-value="nomorRuang"
|
||||||
|
placeholder="Pilih Ruang Pemeriksaan"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="room-select mb-3"
|
||||||
|
></v-select>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<v-btn
|
||||||
|
color="#3F51B5"
|
||||||
|
variant="flat"
|
||||||
|
class="flex-1 text-white text-none font-weight-bold"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('pemeriksaan-awal')"
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-stethoscope</v-icon>
|
||||||
|
Pemeriksaan Awal
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="#3F51B5"
|
||||||
|
variant="flat"
|
||||||
|
class="flex-1 text-white text-none font-weight-bold ml-2"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('panggil-pemeriksaan')"
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-bullhorn</v-icon>
|
||||||
|
Panggil Pemeriksaan
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import GpStatusBadge from './GpStatusBadge.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
queueNumber: { type: String, default: '' },
|
||||||
|
roomName: { type: String, default: '' },
|
||||||
|
roomOptions: { type: Array, default: () => [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'action-pending', 'action-selesai', 'update:selectedRoom']);
|
||||||
|
|
||||||
|
const selectedRoom = ref(props.roomOptions?.[0]?.nomorRuang || null);
|
||||||
|
watch(selectedRoom, (newVal) => {
|
||||||
|
emit('update:selectedRoom', newVal);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-current-patient {
|
||||||
|
background: white;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
.patient-big-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.queue-number {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--gp-text-primary, #1f2937);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.room-indicator {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-room-number, #F97316);
|
||||||
|
margin-top: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.input-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gp-text-secondary, #6b7280);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.room-select {
|
||||||
|
/* vuetify default is fine */
|
||||||
|
}
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.action-badge {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.action-badge:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="gp-monitor-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<v-icon size="18" class="mr-2" color="#3F51B5">mdi-monitor-dashboard</v-icon>
|
||||||
|
<span class="title">MONITOR RUANG</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-box box-success">
|
||||||
|
<div class="stat-label text-success">TERSEDIA</div>
|
||||||
|
<div class="stat-value text-success">{{ availableCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box box-danger ml-2">
|
||||||
|
<div class="stat-label text-danger">SIBUK</div>
|
||||||
|
<div class="stat-value text-danger">{{ busyCount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rooms-list">
|
||||||
|
<GpRoomMonitorCard
|
||||||
|
v-for="room in rooms"
|
||||||
|
:key="room.id"
|
||||||
|
:room-name="room.name"
|
||||||
|
:active-patient="room.activePatient"
|
||||||
|
:clinic-name="room.clinicName"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import GpRoomMonitorCard from './GpRoomMonitorCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rooms: { type: Array, default: () => [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableCount = computed(() => props.rooms.filter(r => !r.activePatient).length);
|
||||||
|
const busyCount = computed(() => props.rooms.filter(r => r.activePatient).length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-monitor-sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: white;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-text-primary, #1f2937);
|
||||||
|
}
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
.stat-box {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.box-success {
|
||||||
|
background: #ECFDF5;
|
||||||
|
border: 1px solid #D1FAE5;
|
||||||
|
}
|
||||||
|
.box-danger {
|
||||||
|
background: #FFF7ED;
|
||||||
|
border: 1px solid #FFEDD5;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.text-success { color: var(--gp-success, #10b981); }
|
||||||
|
.text-danger { color: var(--gp-room-number, #F97316); } /* Orange */
|
||||||
|
|
||||||
|
.rooms-list {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="gp-queue-list-container">
|
||||||
|
<div class="list-header">
|
||||||
|
<span class="title">DAFTAR ANTRIAN</span>
|
||||||
|
<span class="count-badge">{{ patients.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-body">
|
||||||
|
<GpQueueListItem
|
||||||
|
v-for="patient in patients"
|
||||||
|
:key="patient.id || patient.noAntrian"
|
||||||
|
:queue-number="patient.noAntrian"
|
||||||
|
:status="patient.status"
|
||||||
|
:is-active="patient.isActive"
|
||||||
|
@view-detail="$emit('view-detail', patient)"
|
||||||
|
@process-patient="$emit('process-patient', patient)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import GpQueueListItem from './GpQueueListItem.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
patients: { type: Array, default: () => [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['view-detail', 'process-patient']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-queue-list-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
background: white;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.list-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #EEF2F6; /* Light blue-gray from design */
|
||||||
|
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
.count-badge {
|
||||||
|
background: #D1D5DB; /* Gray */
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.list-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['gp-queue-list-item', { active: isActive }]">
|
||||||
|
<div class="item-left">
|
||||||
|
<span class="queue-number">{{ queueNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-right">
|
||||||
|
<GpStatusBadge v-if="status && !isActive" :status="status" />
|
||||||
|
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
|
||||||
|
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import GpStatusBadge from './GpStatusBadge.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
queueNumber: { type: String, required: true },
|
||||||
|
status: { type: String, default: '' },
|
||||||
|
isActive: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['view-detail', 'process-patient']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-queue-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
background-color: var(--gp-surface, #fff);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.gp-queue-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.gp-queue-list-item.active {
|
||||||
|
background-color: #E3EBFF; /* Light blue highlighting active patient */
|
||||||
|
}
|
||||||
|
.queue-number {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-text-primary, #1f2937);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="gp-room-column">
|
||||||
|
<div class="column-header">
|
||||||
|
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
|
||||||
|
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column-content">
|
||||||
|
<template v-if="currentPatient || queuePatients.length > 0">
|
||||||
|
<GpCurrentPatient
|
||||||
|
v-if="currentPatient"
|
||||||
|
:queue-number="currentPatient.noAntrian"
|
||||||
|
:room-name="currentPatient.roomIndicator"
|
||||||
|
:room-options="roomOptions"
|
||||||
|
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
|
||||||
|
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
|
||||||
|
@action-pending="$emit('action-pending', currentPatient)"
|
||||||
|
@action-selesai="$emit('action-selesai', currentPatient)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GpQueueList
|
||||||
|
:patients="queuePatients"
|
||||||
|
@view-detail="p => $emit('view-detail', p)"
|
||||||
|
@process-patient="p => $emit('process-patient', p)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-column-state">
|
||||||
|
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
|
||||||
|
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import GpCurrentPatient from './GpCurrentPatient.vue';
|
||||||
|
import GpQueueList from './GpQueueList.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
clinicName: { type: String, required: true },
|
||||||
|
currentPatient: { type: Object, default: null },
|
||||||
|
queuePatients: { type: Array, default: () => [] },
|
||||||
|
roomOptions: { type: Array, default: () => [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-room-column {
|
||||||
|
width: 380px;
|
||||||
|
min-width: 380px;
|
||||||
|
background: var(--gp-bg, #F5F7FA);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
.column-header {
|
||||||
|
background: var(--gp-primary, #3F51B5);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.column-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.column-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white; /* Make the whole column body white */
|
||||||
|
}
|
||||||
|
.empty-column-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.empty-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="room-name">{{ roomName }}</span>
|
||||||
|
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
|
||||||
|
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
|
||||||
|
<span v-else class="waiting-text">Menunggu pasien...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
roomName: { type: String, required: true },
|
||||||
|
activePatient: { type: String, default: null }, // e.g. "AI002"
|
||||||
|
clinicName: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBusy = computed(() => !!props.activePatient);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-room-monitor-card {
|
||||||
|
background: var(--gp-surface, #fff);
|
||||||
|
border: 1px solid var(--gp-border, #e5e7eb);
|
||||||
|
border-left: 4px solid var(--gp-success, #10b981);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
.gp-room-monitor-card.is-busy {
|
||||||
|
border-left-color: var(--gp-room-number, #F97316); /* Orange */
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.room-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gp-text-primary, #1f2937);
|
||||||
|
}
|
||||||
|
.room-status {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.text-success { color: var(--gp-success, #10b981); }
|
||||||
|
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.active-patient {
|
||||||
|
color: var(--gp-primary, #3F51B5);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.waiting-text {
|
||||||
|
color: var(--gp-text-secondary, #6b7280);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="['gp-badge', badgeClass]">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: { type: String, required: true },
|
||||||
|
text: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = computed(() => props.text || props.status.toUpperCase());
|
||||||
|
|
||||||
|
const badgeClass = computed(() => {
|
||||||
|
const s = props.status.toLowerCase();
|
||||||
|
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
|
||||||
|
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
|
||||||
|
return 'gp-badge-default';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gp-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
|
||||||
|
.gp-badge-success { background-color: var(--gp-success, #10b981); }
|
||||||
|
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<v-card variant="outlined" class="rounded-lg overflow-hidden">
|
||||||
|
<v-table density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left font-weight-bold">Halaman</th>
|
||||||
|
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="item in flatNavItems" :key="item.path || item.name">
|
||||||
|
<tr :class="{ 'bg-grey-lighten-4': !item.path }">
|
||||||
|
<td :style="{ paddingLeft: item.level * 24 + 'px' }">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon>
|
||||||
|
<span :class="{ 'font-weight-bold': !item.path }">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-checkbox
|
||||||
|
v-if="item.path"
|
||||||
|
:model-value="isAllowed(item.path)"
|
||||||
|
@update:model-value="toggleAccess(item, !!$event)"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
class="d-inline-flex"
|
||||||
|
></v-checkbox>
|
||||||
|
<v-icon v-else icon="mdi-folder-open" size="small" color="grey-lighten-1"></v-icon>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useNavItemsStore } from '~/stores/navItems1';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
pages: {
|
||||||
|
type: Array as () => any[], // Be flexible with legacy data
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:pages']);
|
||||||
|
|
||||||
|
const navItemsStore = useNavItemsStore();
|
||||||
|
|
||||||
|
interface FlatNavItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatNavItems = computed(() => {
|
||||||
|
const result: FlatNavItem[] = [];
|
||||||
|
|
||||||
|
const walk = (items: any[], level = 0) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
result.push({
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
icon: item.icon,
|
||||||
|
level
|
||||||
|
});
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
walk(item.children, level + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(navItemsStore.getNavItems);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAllowed = (path: string) => {
|
||||||
|
if (!props.pages || !Array.isArray(props.pages)) return false;
|
||||||
|
|
||||||
|
return props.pages.some(p => {
|
||||||
|
if (typeof p === 'string') return p === path;
|
||||||
|
if (p && typeof p === 'object' && p.path) return p.path === path;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
|
||||||
|
let newPages = [...props.pages];
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
if (!newPages.includes(item.path)) {
|
||||||
|
newPages.push(item.path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newPages = newPages.filter(p => p !== item.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:pages', newPages);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bg-grey-lighten-4 {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
class="rounded-lg elevation-2 d-flex flex-column ticket-card"
|
||||||
|
color="white"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<!-- Header dengan background biru -->
|
||||||
|
<v-card-title class="ticket-header">
|
||||||
|
<div class="ticket-header-content">
|
||||||
|
<v-icon color="white" class="mr-2">mdi-hospital-box-outline</v-icon>
|
||||||
|
<span class="ticket-header-title">{{ cleanTitle }}</span>
|
||||||
|
<v-chip
|
||||||
|
v-if="extractedQueueNumber"
|
||||||
|
size="small"
|
||||||
|
class="ml-2 ticket-header-chip"
|
||||||
|
color="primary-700"
|
||||||
|
>
|
||||||
|
Antrean: {{ extractedQueueNumber }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<v-card-text class="ticket-content pa-4">
|
||||||
|
|
||||||
|
<div class="timeline-scroll-container flex-grow-1">
|
||||||
|
<v-timeline density="compact" align="start" line-inset="12" line-color="#567EE7">
|
||||||
|
<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="getStepTextClass(index)"
|
||||||
|
>
|
||||||
|
{{ step.label }}
|
||||||
|
</span>
|
||||||
|
<span class="text-caption" :class="getStepDateClass(index)">
|
||||||
|
{{ 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="#567EE7"
|
||||||
|
class="mt-auto font-weight-bold reprint-button text-secondary-500"
|
||||||
|
prepend-icon="mdi-printer"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Cetak Ulang Tiket
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: String,
|
||||||
|
steps: Array,
|
||||||
|
color: String,
|
||||||
|
currentStepLabel: String,
|
||||||
|
queueNumber: String, // Nomor antrean untuk ditampilkan di chip
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentStepIndex = computed(() => {
|
||||||
|
return props.steps.findIndex(step => step.label === props.currentStepLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract nomor antrean dari title jika queueNumber tidak disediakan
|
||||||
|
const extractedQueueNumber = computed(() => {
|
||||||
|
if (props.queueNumber) {
|
||||||
|
return props.queueNumber;
|
||||||
|
}
|
||||||
|
// Extract dari title format: "JIWA (Antrean: 1)" atau "RADIOLOGI (Antrean: 5)"
|
||||||
|
const match = props.title.match(/\(Antrean:\s*(\d+)\)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean title tanpa nomor antrean untuk ditampilkan di header
|
||||||
|
const cleanTitle = computed(() => {
|
||||||
|
// Hapus bagian "(Antrean: X)" dari title
|
||||||
|
return props.title.replace(/\s*\(Antrean:\s*\d+\)/g, '').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Warna berbeda berdasarkan status step (dari _colors.scss)
|
||||||
|
if (index < currentStepIndex.value) {
|
||||||
|
return '#33A484'; // success-600 - hijau untuk completed
|
||||||
|
} else if (index === currentStepIndex.value) {
|
||||||
|
return '#FF8441'; // secondary-500 - orange untuk current
|
||||||
|
}
|
||||||
|
return '#CDD4DC'; // neutral-500 - abu-abu untuk pending
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepTextClass = (index) => {
|
||||||
|
// Warna text label sesuai dengan warna dot
|
||||||
|
if (index < currentStepIndex.value) {
|
||||||
|
return 'text-success-600'; // hijau untuk completed
|
||||||
|
} else if (index === currentStepIndex.value) {
|
||||||
|
return 'text-secondary-500'; // orange untuk current
|
||||||
|
}
|
||||||
|
return 'text-neutral-600'; // abu-abu untuk pending
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepDateClass = (index) => {
|
||||||
|
// Warna text date/time lebih terang
|
||||||
|
if (index < currentStepIndex.value) {
|
||||||
|
return 'text-success-400'; // hijau terang untuk completed
|
||||||
|
} else if (index === currentStepIndex.value) {
|
||||||
|
return 'text-secondary-400'; // orange terang untuk current
|
||||||
|
}
|
||||||
|
return 'text-neutral-500'; // abu-abu untuk pending
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '@/assets/scss/_colors.scss';
|
||||||
|
|
||||||
|
/* =============================================== */
|
||||||
|
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
|
||||||
|
/* =============================================== */
|
||||||
|
.timeline-scroll-container {
|
||||||
|
/* Flex grow untuk mengisi ruang yang tersedia */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 400px;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================== */
|
||||||
|
/* TICKET CARD - FIXED HEIGHT */
|
||||||
|
/* =============================================== */
|
||||||
|
.ticket-card {
|
||||||
|
min-height: 550px !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================== */
|
||||||
|
/* TICKET HEADER */
|
||||||
|
/* =============================================== */
|
||||||
|
.ticket-header {
|
||||||
|
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||||
|
color: $neutral-100;
|
||||||
|
padding: 16px 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(58, 97, 201, 0.2);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-header-title {
|
||||||
|
flex: 1;
|
||||||
|
color: $neutral-100;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-header-chip {
|
||||||
|
color: $neutral-100 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================== */
|
||||||
|
/* TICKET CONTENT */
|
||||||
|
/* =============================================== */
|
||||||
|
.ticket-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================== */
|
||||||
|
/* REPRINT BUTTON - FIXED SIZE */
|
||||||
|
/* =============================================== */
|
||||||
|
.reprint-button {
|
||||||
|
min-height: 48px !important;
|
||||||
|
height: 48px !important;
|
||||||
|
max-height: 48px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
margin-top: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
// Basic test to verify Vitest is working
|
||||||
|
describe('Vitest Setup', () => {
|
||||||
|
it('works correctly', () => {
|
||||||
|
expect(1 + 1).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Basic test for Vue component mounting (optional, depends on having a simple component)
|
||||||
|
/*
|
||||||
|
import MyComponent from './MyComponent.vue'
|
||||||
|
describe('Component Test', () => {
|
||||||
|
it('mounts properly', () => {
|
||||||
|
const wrapper = mount(MyComponent)
|
||||||
|
expect(wrapper.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
*/
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<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">{{ getCurrentTime() }}</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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentTime = () => {
|
||||||
|
// Format jam dengan titik dua (HH:MM:SS)
|
||||||
|
const timeString = new Date().toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
return timeString.replace(/\./g, ':')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~/assets/scss/checkin/dialogs';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="header-modern">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="icon-circle">
|
||||||
|
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1 class="title-modern">Check-in Pasien</h1>
|
||||||
|
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs Minimalis -->
|
||||||
|
<v-tabs
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="updateTab"
|
||||||
|
align-tabs="center"
|
||||||
|
class="tabs-modern mt-3"
|
||||||
|
bg-color="transparent"
|
||||||
|
slider-color="#FB8C00"
|
||||||
|
height="40"
|
||||||
|
>
|
||||||
|
<v-tab value="scan" class="tab-modern">
|
||||||
|
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||||
|
<span>Scan QR</span>
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="manual" class="tab-modern">
|
||||||
|
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
|
||||||
|
<span>Manual</span>
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="generate" class="tab-modern">
|
||||||
|
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
|
||||||
|
<span>Generate QR</span>
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabValue } from '~/types/checkin'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: TabValue
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: TabValue): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const updateTab = (value: TabValue) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Header Modern Minimalis */
|
||||||
|
.header-modern {
|
||||||
|
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-modern::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-circle {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-modern {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-modern {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-tab) {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
min-width: 120px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-tab:hover) {
|
||||||
|
color: rgba(255, 255, 255, 1) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-tab--selected) {
|
||||||
|
color: white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-modern :deep(.v-slider) {
|
||||||
|
background-color: #FB8C00 !important;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
patientId: string;
|
||||||
|
status: string;
|
||||||
|
generatedQRData: string | null;
|
||||||
|
statusOptions: Array<{ title: string; value: string }>;
|
||||||
|
primaryColor: string;
|
||||||
|
generateRandomPatientId: () => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:patientId': [value: string];
|
||||||
|
'update:status': [value: string];
|
||||||
|
'generate': [];
|
||||||
|
'quick-generate': [patientId: string, status: string];
|
||||||
|
'download': [];
|
||||||
|
'copy': [];
|
||||||
|
'share': [];
|
||||||
|
'generate-random-id': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localPatientId = computed({
|
||||||
|
get: () => props.patientId,
|
||||||
|
set: (value) => emit('update:patientId', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const localStatus = computed({
|
||||||
|
get: () => props.status,
|
||||||
|
set: (value) => emit('update:status', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
emit('generate');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickGenerate = (status: string) => {
|
||||||
|
const randomId = props.generateRandomPatientId();
|
||||||
|
emit('quick-generate', randomId, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
emit('download');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
emit('copy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
emit('share');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateRandomId = () => {
|
||||||
|
emit('generate-random-id');
|
||||||
|
};
|
||||||
|
|
||||||
|
const qrContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Function to render QR code
|
||||||
|
const renderQRCode = async (data: string) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Wait a bit more to ensure ref is mounted (especially when switching tabs)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
if (!qrContainerRef.value) {
|
||||||
|
console.warn('QR container ref not available yet, retrying...');
|
||||||
|
// Retry after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (qrContainerRef.value && data) {
|
||||||
|
renderQRCode(data);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear previous content
|
||||||
|
qrContainerRef.value.innerHTML = '';
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(data, {
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
type: 'image/png',
|
||||||
|
quality: 0.92,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF',
|
||||||
|
},
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and append image
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = qrDataUrl;
|
||||||
|
img.alt = 'QR Code';
|
||||||
|
img.style.width = '100%';
|
||||||
|
img.style.maxWidth = '300px';
|
||||||
|
img.style.height = 'auto';
|
||||||
|
img.style.display = 'block';
|
||||||
|
img.style.margin = '0 auto';
|
||||||
|
qrContainerRef.value.appendChild(img);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating QR code:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for generatedQRData changes to render QR code
|
||||||
|
watch(() => props.generatedQRData, async (newData) => {
|
||||||
|
if (newData) {
|
||||||
|
// Use setTimeout to ensure DOM is ready, especially when switching tabs
|
||||||
|
setTimeout(() => {
|
||||||
|
renderQRCode(newData);
|
||||||
|
}, 200);
|
||||||
|
} else if (qrContainerRef.value) {
|
||||||
|
// Clear container if data is cleared
|
||||||
|
qrContainerRef.value.innerHTML = '';
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Also watch for when component is mounted and ref is ready
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.generatedQRData) {
|
||||||
|
// Delay to ensure ref is mounted
|
||||||
|
setTimeout(() => {
|
||||||
|
renderQRCode(props.generatedQRData!);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Status Header -->
|
||||||
|
<div class="status-header mb-3">
|
||||||
|
<div class="status-icon-wrapper">
|
||||||
|
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="status-text">
|
||||||
|
<h3 class="status-title">Generate QR Code untuk Testing</h3>
|
||||||
|
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Preset Buttons -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleQuickGenerate('ALLOWED')"
|
||||||
|
class="text-none"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-check-circle</v-icon>
|
||||||
|
Test ALLOWED
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
@click="handleQuickGenerate('NOT_ALLOWED')"
|
||||||
|
class="text-none"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-clock-alert</v-icon>
|
||||||
|
Test NOT_ALLOWED
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Generate -->
|
||||||
|
<v-form @submit.prevent="handleGenerate">
|
||||||
|
<v-text-field
|
||||||
|
v-model="localPatientId"
|
||||||
|
label="ID Pasien"
|
||||||
|
placeholder="Contoh: P-123456"
|
||||||
|
prepend-inner-icon="mdi-identifier"
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
class="input-modern mb-3"
|
||||||
|
density="comfortable"
|
||||||
|
clearable
|
||||||
|
hide-details="auto"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="handleGenerateRandomId"
|
||||||
|
title="Generate Random ID"
|
||||||
|
>
|
||||||
|
<v-icon size="20">mdi-refresh</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-model="localStatus"
|
||||||
|
label="Status Check-in"
|
||||||
|
:items="statusOptions"
|
||||||
|
prepend-inner-icon="mdi-shield-check"
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
class="input-modern mb-4"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
></v-select>
|
||||||
|
|
||||||
|
<div class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
class="btn-primary-modern btn-centered"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
elevation="0"
|
||||||
|
:disabled="!localPatientId"
|
||||||
|
>
|
||||||
|
<v-icon start size="20">mdi-qrcode-plus</v-icon>
|
||||||
|
Generate QR Code
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
<!-- QR Code Display -->
|
||||||
|
<div v-if="props.generatedQRData" class="qr-display mt-6">
|
||||||
|
<v-card variant="outlined" class="pa-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
|
||||||
|
<div ref="qrContainerRef" class="qr-code-container mb-4"></div>
|
||||||
|
|
||||||
|
<v-chip :color="localStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
|
||||||
|
<v-icon start>{{ localStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
|
||||||
|
{{ localStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
|
<p class="text-body-2 text-grey mb-4">
|
||||||
|
Data: {{ props.generatedQRData }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
@click="handleDownload"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start size="18">mdi-download</v-icon>
|
||||||
|
Download
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
@click="handleCopy"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start size="18">mdi-content-copy</v-icon>
|
||||||
|
Copy
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
@click="handleShare"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start size="18">mdi-share-variant</v-icon>
|
||||||
|
Share
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<v-alert
|
||||||
|
type="success"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4 text-body-2"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-information</v-icon>
|
||||||
|
</template>
|
||||||
|
<strong>Cara menggunakan untuk Testing:</strong>
|
||||||
|
<ol class="ml-4 mt-2">
|
||||||
|
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
|
||||||
|
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
|
||||||
|
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
|
||||||
|
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
|
||||||
|
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
|
||||||
|
</ol>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~/assets/scss/checkin/components';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
max-width="800"
|
||||||
|
persistent
|
||||||
|
transition="dialog-transition"
|
||||||
|
scrim="rgba(0, 0, 0, 0.5)"
|
||||||
|
class="blur-dialog"
|
||||||
|
>
|
||||||
|
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||||
|
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
|
||||||
|
<h2 class="text-h5 font-weight-bold text-white mb-2">
|
||||||
|
<v-icon color="white" class="mr-2">mdi-history</v-icon>
|
||||||
|
Riwayat Check-in
|
||||||
|
</h2>
|
||||||
|
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-card-text class="pa-6">
|
||||||
|
<!-- Filter dan Search -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="search"
|
||||||
|
@update:model-value="$emit('update:search', $event)"
|
||||||
|
label="Cari ID Pasien atau Nomor Antrean"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="dateFilter"
|
||||||
|
@update:model-value="$emit('update:dateFilter', $event || '')"
|
||||||
|
type="date"
|
||||||
|
label="Filter Tanggal"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
class="date-picker-field"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="2">
|
||||||
|
<v-select
|
||||||
|
:model-value="statusFilter"
|
||||||
|
@update:model-value="$emit('update:statusFilter', $event)"
|
||||||
|
label="Filter Status"
|
||||||
|
:items="statusOptions"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
@click="$emit('clear')"
|
||||||
|
:disabled="history.length === 0"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-delete</v-icon>
|
||||||
|
Hapus Semua
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History List -->
|
||||||
|
<div v-if="filteredHistory.length > 0" class="history-list">
|
||||||
|
<v-card
|
||||||
|
v-for="(item, index) in filteredHistory"
|
||||||
|
:key="index"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-3 history-item"
|
||||||
|
:class="getStatusClass(item.status)"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex justify-space-between align-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-center mb-2">
|
||||||
|
<v-chip
|
||||||
|
:color="getStatusColor(item.status)"
|
||||||
|
size="small"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
|
||||||
|
{{ getStatusText(item.status) }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
color="grey-lighten-1"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
{{ item.method }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="text-body-1 font-weight-bold mb-1">
|
||||||
|
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
|
||||||
|
ID Pasien: {{ item.patientId }}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
|
||||||
|
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
|
||||||
|
Nomor Antrean: {{ item.queueNumber }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<v-chip
|
||||||
|
size="x-small"
|
||||||
|
variant="outlined"
|
||||||
|
color="grey-darken-1"
|
||||||
|
>
|
||||||
|
<v-icon start size="14">mdi-clock-outline</v-icon>
|
||||||
|
{{ formatDateTime(item.checkInTime) }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-if="item.checkInDate"
|
||||||
|
size="x-small"
|
||||||
|
variant="outlined"
|
||||||
|
color="grey-darken-1"
|
||||||
|
>
|
||||||
|
<v-icon start size="14">mdi-calendar</v-icon>
|
||||||
|
{{ formatDate(item.checkInDate) }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
@click="$emit('delete-item', index)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="text-center py-12">
|
||||||
|
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
|
||||||
|
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
|
||||||
|
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="pa-6 pt-0">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
class="text-white font-weight-bold text-none"
|
||||||
|
size="large"
|
||||||
|
variant="flat"
|
||||||
|
@click="$emit('update:modelValue', false)"
|
||||||
|
prepend-icon="mdi-close"
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckInHistoryItem, HistoryStatus } from '~/types/checkin'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
search: string
|
||||||
|
dateFilter: string
|
||||||
|
statusFilter: HistoryStatus | ''
|
||||||
|
filteredHistory: CheckInHistoryItem[]
|
||||||
|
history: CheckInHistoryItem[] | ReadonlyArray<CheckInHistoryItem>
|
||||||
|
statusOptions: Array<{ title: string; value: string }>
|
||||||
|
primaryColor: string
|
||||||
|
getStatusColor: (status: string) => string
|
||||||
|
getStatusIcon: (status: string) => string
|
||||||
|
getStatusText: (status: string) => string
|
||||||
|
getStatusClass: (status: string) => string
|
||||||
|
formatDateTime: (dateString: string) => string
|
||||||
|
formatDate: (dateString: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'update:search': [value: string]
|
||||||
|
'update:dateFilter': [value: string]
|
||||||
|
'update:statusFilter': [value: HistoryStatus | '']
|
||||||
|
'delete-item': [index: number]
|
||||||
|
'clear': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~/assets/scss/checkin/components';
|
||||||
|
@import '~/assets/scss/checkin/dialogs';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
primaryColor: string;
|
||||||
|
secondaryColor: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
'submit': [];
|
||||||
|
'open-history': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const manualForm = ref<{ validate: () => boolean; resetValidation: () => void; reset: () => void } | null>(null);
|
||||||
|
|
||||||
|
const localValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('submit');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenHistory = () => {
|
||||||
|
emit('open-history');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose form ref for parent component
|
||||||
|
defineExpose({
|
||||||
|
form: manualForm,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Status Header -->
|
||||||
|
<div class="status-header mb-3">
|
||||||
|
<div class="status-icon-wrapper">
|
||||||
|
<v-icon :color="secondaryColor" size="24">mdi-keyboard</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="status-text">
|
||||||
|
<h3 class="status-title">Input Manual</h3>
|
||||||
|
<p class="status-subtitle">Masukkan nomor antrean atau ID pasien</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-form @submit.prevent="handleSubmit" ref="manualForm">
|
||||||
|
<v-text-field
|
||||||
|
v-model="localValue"
|
||||||
|
label="Nomor Antrean / ID Pasien"
|
||||||
|
placeholder="Contoh: P12345"
|
||||||
|
prepend-inner-icon="mdi-account-card-details"
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
class="input-modern mb-4"
|
||||||
|
required
|
||||||
|
:rules="[v => !!v || 'Field ini wajib diisi']"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
>
|
||||||
|
<template v-slot:append-inner>
|
||||||
|
<v-icon :color="localValue ? 'success' : 'grey'" size="20">
|
||||||
|
{{ localValue ? 'mdi-check-circle' : 'mdi-circle-outline' }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
class="btn-primary-modern btn-centered"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
elevation="0"
|
||||||
|
>
|
||||||
|
<v-icon start size="20">mdi-login</v-icon>
|
||||||
|
Check-in Sekarang
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
<!-- Quick Access Buttons -->
|
||||||
|
<div class="quick-actions mt-4">
|
||||||
|
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
class="text-none"
|
||||||
|
@click="handleOpenHistory"
|
||||||
|
>
|
||||||
|
<v-icon start size="18">mdi-history</v-icon>
|
||||||
|
Riwayat
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~/assets/scss/checkin/components';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
max-width="800"
|
||||||
|
persistent
|
||||||
|
transition="dialog-transition"
|
||||||
|
scrim="rgba(0, 0, 0, 0.5)"
|
||||||
|
class="blur-dialog"
|
||||||
|
>
|
||||||
|
<v-card class="rounded-xl dialog-card" elevation="24">
|
||||||
|
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #FB8C00 0%, #F57C00 100%);">
|
||||||
|
<h2 class="text-h5 font-weight-bold text-white mb-2">
|
||||||
|
<v-icon color="white" class="mr-2">mdi-qrcode-scan</v-icon>
|
||||||
|
Riwayat QR Scan
|
||||||
|
</h2>
|
||||||
|
<p class="text-body-2 text-white opacity-90">Daftar QR code yang telah di-scan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-card-text class="pa-6">
|
||||||
|
<!-- Filter dan Search -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12" md="5">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="search"
|
||||||
|
@update:model-value="$emit('update:search', $event)"
|
||||||
|
label="Cari Data QR Code"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="dateFilter"
|
||||||
|
@update:model-value="$emit('update:dateFilter', $event || '')"
|
||||||
|
type="date"
|
||||||
|
label="Filter Tanggal"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
class="date-picker-field"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
@click="$emit('clear')"
|
||||||
|
:disabled="history.length === 0"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-delete</v-icon>
|
||||||
|
Hapus Semua
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR History List -->
|
||||||
|
<div v-if="filteredHistory.length > 0" class="history-list">
|
||||||
|
<v-card
|
||||||
|
v-for="(item, index) in filteredHistory"
|
||||||
|
:key="index"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-3 history-item"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex justify-space-between align-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="text-body-1 font-weight-bold mb-1">
|
||||||
|
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-qrcode</v-icon>
|
||||||
|
Data QR: {{ item.data }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<v-chip
|
||||||
|
size="x-small"
|
||||||
|
variant="outlined"
|
||||||
|
color="grey-darken-1"
|
||||||
|
>
|
||||||
|
<v-icon start size="14">mdi-clock-outline</v-icon>
|
||||||
|
{{ item.time }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
size="x-small"
|
||||||
|
variant="outlined"
|
||||||
|
color="grey-darken-1"
|
||||||
|
>
|
||||||
|
<v-icon start size="14">mdi-calendar</v-icon>
|
||||||
|
{{ item.date }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
@click="$emit('use-data', item.data)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-refresh</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
@click="$emit('delete-item', index)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="text-center py-12">
|
||||||
|
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-qrcode-scan</v-icon>
|
||||||
|
<p class="text-h6 text-grey mb-2">Belum ada riwayat QR scan</p>
|
||||||
|
<p class="text-body-2 text-grey">Riwayat QR scan akan muncul di sini setelah Anda melakukan scan</p>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="pa-6 pt-0">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
class="text-white font-weight-bold text-none"
|
||||||
|
size="large"
|
||||||
|
variant="flat"
|
||||||
|
@click="$emit('update:modelValue', false)"
|
||||||
|
prepend-icon="mdi-close"
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ScannedQRHistoryItem } from '~/types/checkin'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
search: string
|
||||||
|
dateFilter: string
|
||||||
|
filteredHistory: ScannedQRHistoryItem[]
|
||||||
|
history: ScannedQRHistoryItem[] | ReadonlyArray<ScannedQRHistoryItem>
|
||||||
|
primaryColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'update:search': [value: string]
|
||||||
|
'update:dateFilter': [value: string]
|
||||||
|
'use-data': [data: string]
|
||||||
|
'delete-item': [index: number]
|
||||||
|
'clear': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~/assets/scss/checkin/components';
|
||||||
|
@import '~/assets/scss/checkin/dialogs';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isScanning: boolean;
|
||||||
|
hasCamera: boolean;
|
||||||
|
cameraChecking: boolean;
|
||||||
|
cameraReady: boolean;
|
||||||
|
primaryColor: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'start-scanning': [];
|
||||||
|
'stop-scanning': [];
|
||||||
|
'test-camera': [];
|
||||||
|
'open-history': [];
|
||||||
|
'open-qr-history': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleStartScanning = () => {
|
||||||
|
emit('start-scanning');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopScanning = () => {
|
||||||
|
emit('stop-scanning');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestCamera = () => {
|
||||||
|
emit('test-camera');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenHistory = () => {
|
||||||
|
emit('open-history');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenQRHistory = () => {
|
||||||
|
emit('open-qr-history');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Status Header -->
|
||||||
|
<div class="status-header mb-3">
|
||||||
|
<div class="status-icon-wrapper">
|
||||||
|
<v-icon :color="primaryColor" size="24">mdi-qrcode-scan</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="status-text">
|
||||||
|
<h3 class="status-title">
|
||||||
|
{{ isScanning ? 'Arahkan Kamera ke QR Code' : 'Siap untuk Scan QR Code' }}
|
||||||
|
</h3>
|
||||||
|
<p class="status-subtitle">
|
||||||
|
{{ isScanning ? 'Pastikan QR code terlihat jelas dan tidak terpotong' : 'Klik tombol di bawah untuk memulai scan' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Status Check -->
|
||||||
|
<div v-if="cameraChecking" class="text-center mb-4">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
:color="primaryColor"
|
||||||
|
size="24"
|
||||||
|
class="mr-2"
|
||||||
|
></v-progress-circular>
|
||||||
|
<span class="text-body-2 text-grey">Memeriksa ketersediaan kamera...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Not Available Warning -->
|
||||||
|
<v-alert
|
||||||
|
v-else-if="!hasCamera && !isScanning"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-camera-off</v-icon>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<p class="font-weight-bold mb-1">Kamera tidak terdeteksi</p>
|
||||||
|
<p class="text-body-2 mb-0">
|
||||||
|
Perangkat Anda tidak memiliki kamera atau kamera tidak dapat diakses.
|
||||||
|
Silakan gunakan tab <strong>Manual</strong> untuk input data secara manual.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- QR Scanner Area dengan webcam -->
|
||||||
|
<div class="qr-scanner-container mb-4">
|
||||||
|
<div v-if="!isScanning" class="qr-placeholder">
|
||||||
|
<div class="scanner-overlay">
|
||||||
|
<div class="corner corner-tl"></div>
|
||||||
|
<div class="corner corner-tr"></div>
|
||||||
|
<div class="corner corner-bl"></div>
|
||||||
|
<div class="corner corner-br"></div>
|
||||||
|
<div class="scan-line"></div>
|
||||||
|
</div>
|
||||||
|
<v-icon size="64" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
|
||||||
|
</div>
|
||||||
|
<div v-else class="qr-reader-container">
|
||||||
|
<div class="scanner-status mb-2">
|
||||||
|
<v-chip color="success" size="small" class="mr-2">
|
||||||
|
<v-icon start size="16">mdi-camera</v-icon>
|
||||||
|
Kamera Aktif
|
||||||
|
</v-chip>
|
||||||
|
<span class="text-caption text-grey">Preview kamera sedang berjalan</span>
|
||||||
|
</div>
|
||||||
|
<div id="qr-reader" class="qr-reader-wrapper">
|
||||||
|
<div v-if="!cameraReady" class="scanner-loading-overlay">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="white"
|
||||||
|
size="48"
|
||||||
|
></v-progress-circular>
|
||||||
|
<p class="text-white mt-4">Memuat kamera...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scanner-instruction">
|
||||||
|
<v-icon :color="primaryColor" size="20" class="mr-2">mdi-information</v-icon>
|
||||||
|
<span class="text-body-2">Arahkan kamera ke QR code. Pastikan QR code berada dalam kotak pemindaian.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button Minimalis -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<v-btn
|
||||||
|
v-if="!isScanning"
|
||||||
|
class="btn-primary-modern btn-centered"
|
||||||
|
size="large"
|
||||||
|
elevation="0"
|
||||||
|
@click="handleStartScanning"
|
||||||
|
:disabled="!hasCamera && !cameraChecking"
|
||||||
|
>
|
||||||
|
<v-icon start size="20">mdi-camera</v-icon>
|
||||||
|
{{ hasCamera ? 'Mulai Scan QR' : 'Kamera Tidak Tersedia' }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
class="btn-stop-modern btn-centered"
|
||||||
|
size="large"
|
||||||
|
elevation="0"
|
||||||
|
@click="handleStopScanning"
|
||||||
|
>
|
||||||
|
<v-icon start size="20">mdi-camera-off</v-icon>
|
||||||
|
Stop Scan
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info tambahan -->
|
||||||
|
<div class="info-card mt-3">
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
:color="primaryColor"
|
||||||
|
class="text-body-2 info-alert-centered"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center justify-center">
|
||||||
|
<v-icon size="16" class="mr-2">mdi-lightbulb-outline</v-icon>
|
||||||
|
<span style="font-size: 12px;">Tips: Pastikan pencahayaan cukup untuk hasil scan optimal</span>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Camera Button (Debug) -->
|
||||||
|
<div class="test-camera-section mt-2 mb-2">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
class="text-none btn-centered-small btn-test-camera"
|
||||||
|
@click="handleTestCamera"
|
||||||
|
>
|
||||||
|
<v-icon start size="18" color="#1565C0">mdi-camera</v-icon>
|
||||||
|
Test Kamera
|
||||||
|
</v-btn>
|
||||||
|
<p class="text-caption text-grey text-center mt-1" style="font-size: 11px;">
|
||||||
|
Klik untuk menguji apakah browser dapat mengakses kamera
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Access Buttons -->
|
||||||
|
<div class="quick-actions mt-4">
|
||||||
|
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:color="primaryColor"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
class="text-none"
|
||||||
|
@click="handleOpenHistory"
|
||||||
|
>
|
||||||
|
<v-icon start size="18">mdi-history</v-icon>
|
||||||
|
Riwayat Check-in
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
class="text-none"
|
||||||
|
@click="handleOpenQRHistory"
|
||||||
|
>
|
||||||
|
<v-icon start size="18" color="#1565C0">mdi-qrcode-scan</v-icon>
|
||||||
|
Riwayat QR Scan
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~/assets/scss/checkin/components';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stats-footer-modern mt-3">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="4">
|
||||||
|
<div class="stat-card-modern">
|
||||||
|
<div class="stat-icon-modern">
|
||||||
|
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">{{ todayCount }}</div>
|
||||||
|
<div class="stat-label">Hari Ini</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4">
|
||||||
|
<div class="stat-card-modern">
|
||||||
|
<div class="stat-icon-modern">
|
||||||
|
<v-icon color="success" size="20">mdi-check-circle</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">{{ completedCount }}</div>
|
||||||
|
<div class="stat-label">Selesai</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4">
|
||||||
|
<div class="stat-card-modern">
|
||||||
|
<div class="stat-icon-modern">
|
||||||
|
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">{{ pendingCount }}</div>
|
||||||
|
<div class="stat-label">Menunggu</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { CheckInHistoryItem } from '~/types/checkin'
|
||||||
|
import { PRIMARY_COLOR, SECONDARY_COLOR } from '~/constants/checkin'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
history: CheckInHistoryItem[]
|
||||||
|
primaryColor?: string
|
||||||
|
secondaryColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
primaryColor: PRIMARY_COLOR,
|
||||||
|
secondaryColor: SECONDARY_COLOR,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed untuk menghitung stats
|
||||||
|
const todayCount = computed(() => {
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
return props.history.filter(item => {
|
||||||
|
const itemDate = new Date(item.checkInDate).toDateString()
|
||||||
|
return itemDate === today
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedCount = computed(() => {
|
||||||
|
return props.history.filter(item =>
|
||||||
|
item.status === 'success' || item.status === 'ALLOWED'
|
||||||
|
).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingCount = computed(() => {
|
||||||
|
return props.history.filter(item =>
|
||||||
|
item.status === 'pending' || item.status === 'NOT_ALLOWED'
|
||||||
|
).length
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Stats Footer Modern */
|
||||||
|
.stats-footer-modern {
|
||||||
|
animation: slideUp 0.5s ease-out 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: #1565C0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-modern:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-modern {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 8px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<table class="ticket-table" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td class="ticket-content">
|
||||||
|
<div class="header">
|
||||||
|
<div class="hospital-name">RSUD DR. SAIFUL ANWAR</div>
|
||||||
|
<div class="hospital-address">Jl. Jaksa Agung Suprapto No.2, Malang</div>
|
||||||
|
<div class="ticket-title">Tiket Antrean</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="no-antrian">{{ noAntrianDisplay }}</div>
|
||||||
|
|
||||||
|
<div class="klinik-name">{{ ruangInfo || klinik }}</div>
|
||||||
|
|
||||||
|
<div class="qr-code-section">
|
||||||
|
<div class="qr-code">
|
||||||
|
<img :src="qrCodeImage" alt="QR Code" />
|
||||||
|
</div>
|
||||||
|
<div class="barcode-number">{{ barcode }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-label-wrapper">
|
||||||
|
<span class="info-label-text">Tanggal</span><span class="info-colon">:</span>
|
||||||
|
</div>
|
||||||
|
<span class="info-value">{{ tanggal }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-label-wrapper">
|
||||||
|
<span class="info-label-text">Waktu</span><span class="info-colon">:</span>
|
||||||
|
</div>
|
||||||
|
<span class="info-value">{{ waktu }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-label-wrapper">
|
||||||
|
<span class="info-label-text">Shift</span><span class="info-colon">:</span>
|
||||||
|
</div>
|
||||||
|
<span class="info-value">{{ shift }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-label-wrapper">
|
||||||
|
<span class="info-label-text">Pembayaran</span><span class="info-colon">:</span>
|
||||||
|
</div>
|
||||||
|
<span class="info-value">{{ pembayaran }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="namaDokter" class="info-row">
|
||||||
|
<div class="info-label-wrapper">
|
||||||
|
<span class="info-label-text">Dokter</span><span class="info-colon">:</span>
|
||||||
|
</div>
|
||||||
|
<span class="info-value">{{ namaDokter }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ThermalPrintData } from '@/composables/useThermalPrint';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: ThermalPrintData;
|
||||||
|
qrCodeImage: string;
|
||||||
|
noAntrianDisplay: string;
|
||||||
|
ruangInfo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
barcode,
|
||||||
|
klinik,
|
||||||
|
shift,
|
||||||
|
pembayaran,
|
||||||
|
tanggal,
|
||||||
|
waktu,
|
||||||
|
namaDokter
|
||||||
|
} = props.data;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticket-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 2px dashed #000;
|
||||||
|
margin: 0;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table td {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-content {
|
||||||
|
padding: 2mm 4mm 4mm 4mm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 1mm;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hospital-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5mm;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hospital-address {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5mm;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1mm;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-section {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5mm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: 45mm;
|
||||||
|
height: 45mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5mm;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-number {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: -0.5mm;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
margin-top: 2mm;
|
||||||
|
text-align: left;
|
||||||
|
border-top: 1px dashed #000;
|
||||||
|
padding-top: 1mm;
|
||||||
|
padding-left: 3mm;
|
||||||
|
padding-right: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
margin-bottom: 0.8mm;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label-text {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
word-break: keep-all;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-colon {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-right: 2mm;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label-wrapper {
|
||||||
|
display: flex;
|
||||||
|
width: 22mm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-antrian {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0.3mm 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 0.3mm;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
padding-bottom: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 2mm;
|
||||||
|
padding-top: 1mm;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
border-top: 2px solid #000;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
margin-bottom: 0.5mm;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<v-snackbar v-model="showModel" :color="color" :timeout="timeout">
|
||||||
|
<span class="body-3">{{ message }}</span>
|
||||||
|
<template #actions>
|
||||||
|
<v-btn variant="text" size="small" @click="showModel = false">
|
||||||
|
{{ closeText }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'success'
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
type: Number,
|
||||||
|
default: 3000
|
||||||
|
},
|
||||||
|
closeText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Tutup'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const showModel = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
|
||||||
|
.body-3 {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<v-avatar :size="size" :class="avatarClass">
|
||||||
|
<v-img
|
||||||
|
v-if="src"
|
||||||
|
:src="src"
|
||||||
|
:alt="alt"
|
||||||
|
:lazy-src="defaultAvatar"
|
||||||
|
></v-img>
|
||||||
|
<v-img
|
||||||
|
v-else
|
||||||
|
:src="defaultAvatar"
|
||||||
|
:alt="alt"
|
||||||
|
></v-img>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { DEFAULT_AVATAR } from '@/constants/avatar'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: 'User Avatar'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 44
|
||||||
|
},
|
||||||
|
avatarClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultAvatar = DEFAULT_AVATAR
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="group-label">
|
||||||
|
<v-icon v-if="icon" size="18" class="icon-label">{{ icon }}</v-icon>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$neutral-100: #FFFFFF;
|
||||||
|
$neutral-400: #E5F7FA;
|
||||||
|
$primary-600: #FFA532;
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
background: $neutral-100;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border: 1px solid $neutral-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $primary-600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
color: $primary-600 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-header" :class="`page-header-${theme}`">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-icon">
|
||||||
|
<NuxtImg
|
||||||
|
v-if="logo"
|
||||||
|
:src="logo"
|
||||||
|
:alt="title"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
class="header-logo"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
<v-icon v-else size="28" 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'
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
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', 'accent', or 'warning'
|
||||||
|
validator: (value) => ['primary', 'secondary', 'success', 'accent', 'warning'].includes(value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['add-click']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
$font-weight-regular: 400;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-primary {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-secondary {
|
||||||
|
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(241, 111, 41, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-success {
|
||||||
|
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(51, 164, 132, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-accent {
|
||||||
|
// Accent tidak ada di design system, menggunakan secondary sebagai fallback
|
||||||
|
background: linear-gradient(135deg, var(--color-secondary-500) 0%, var(--color-secondary-600) 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(255, 132, 65, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-warning {
|
||||||
|
// Warning tidak ada di design system, menggunakan secondary (orange) sebagai fallback
|
||||||
|
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(241, 111, 41, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 28px;
|
||||||
|
height: 80px;
|
||||||
|
color: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-right: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 50%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-neutral-100);
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
color: var(--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: var(--color-primary-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn-secondary {
|
||||||
|
color: var(--color-secondary-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn-success {
|
||||||
|
color: var(--color-success-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn-accent {
|
||||||
|
// Accent tidak ada di design system, menggunakan secondary sebagai fallback
|
||||||
|
color: var(--color-secondary-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn-warning {
|
||||||
|
// Warning tidak ada di design system, menggunakan secondary (orange) sebagai fallback
|
||||||
|
color: var(--color-secondary-600) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="dialogModel" max-width="800px" persistent>
|
||||||
|
<v-card class="dialog-card">
|
||||||
|
<v-card-title class="dialog-header">
|
||||||
|
<span class="headline-4">{{ title }}</span>
|
||||||
|
<v-btn icon variant="text" size="small" class="btn-close" @click="dialogModel = false">
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text class="dialog-content">
|
||||||
|
<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-card {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
|
||||||
|
color: var(--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: 600;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
color: var(--color-neutral-100) !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 24px !important;
|
||||||
|
background: var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid var(--color-neutral-400);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card:hover {
|
||||||
|
border-color: var(--color-secondary-600);
|
||||||
|
background: var(--color-secondary-300);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(6, 113, 224, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-name {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
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,114 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* DialogDetailEvent — Dialog untuk menampilkan detail jadwal dokter
|
||||||
|
* yang diklik di kalender. Mendukung tampilan Rutin dan Adhoc.
|
||||||
|
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Kontrol buka/tutup dialog (v-model) */
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
/** Data event yang sedang ditampilkan */
|
||||||
|
event: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'delete']);
|
||||||
|
|
||||||
|
/** Computed v-model proxy untuk dialog visibility */
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit event delete ke parent untuk menghapus jadwal.
|
||||||
|
*/
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('delete', props.event?.id);
|
||||||
|
dialogOpen.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog v-model="dialogOpen" max-width="420px">
|
||||||
|
<v-card rounded="lg" class="pa-0">
|
||||||
|
<v-card-title class="pa-5" :class="event?.tipe === 'Adhoc' ? 'bg-secondary' : 'bg-primary'"
|
||||||
|
style="color:white;">
|
||||||
|
<div class="d-flex justify-space-between align-center w-100">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<v-icon>{{ event?.tipe === 'Adhoc' ? 'mdi-calendar-clock' : 'mdi-calendar-sync'
|
||||||
|
}}</v-icon>
|
||||||
|
<span class="ml-2 text-h6 font-weight-bold">Jadwal {{ event?.tipe }}</span>
|
||||||
|
</div>
|
||||||
|
<v-btn icon="mdi-close" variant="text" size="small" style="color:white;"
|
||||||
|
@click="dialogOpen = false" />
|
||||||
|
</div>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-6" v-if="event">
|
||||||
|
<v-list density="compact" class="pa-0">
|
||||||
|
<v-list-item class="px-0">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary" size="small" class="mr-3">mdi-hospital-building</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption text-grey-darken-1">Poli / Klinik</div>
|
||||||
|
<div class="text-body-2 font-weight-bold">{{ event.poli }}</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
|
<v-list-item class="px-0">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary" size="small" class="mr-3">mdi-doctor</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption text-grey-darken-1">Dokter</div>
|
||||||
|
<div class="text-body-2 font-weight-bold">{{ event.dokter }}</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
|
<v-list-item class="px-0">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary" size="small" class="mr-3">mdi-calendar</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption text-grey-darken-1">Tanggal</div>
|
||||||
|
<div class="text-body-2 font-weight-bold">{{ event.tanggal }}</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- Rutin: tampilkan hari -->
|
||||||
|
<template v-if="event.tipe === 'Rutin' && event.hari">
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
<v-list-item class="px-0">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary" size="small" class="mr-3">mdi-calendar-week</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption text-grey-darken-1">Hari Praktek</div>
|
||||||
|
<div class="text-body-2 font-weight-bold">{{ event.hari }}</div>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Adhoc: tampilkan shift -->
|
||||||
|
<template v-if="event.tipe === 'Adhoc' && event.shift">
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
<v-list-item class="px-0">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="secondary" size="small" class="mr-3">mdi-clock-outline</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption text-grey-darken-1">Waktu</div>
|
||||||
|
<div class="text-body-2 font-weight-bold">{{ event.shift }}</div>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-5 justify-space-between">
|
||||||
|
<v-btn color="error" variant="text" prepend-icon="mdi-trash-can-outline" @click="handleDelete">
|
||||||
|
Hapus
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="primary" variant="flat" @click="dialogOpen = false">Tutup</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* DialogTambahJadwal — Dialog form untuk menambahkan jadwal dokter baru.
|
||||||
|
* Mendukung dua tipe jadwal: Rutin (mingguan berulang) dan Adhoc (satu kali).
|
||||||
|
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Kontrol buka/tutup dialog (v-model) */
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
/** Daftar klinik untuk referensi nama poli */
|
||||||
|
clinicList: { type: Array, default: () => [] },
|
||||||
|
/** ID poli yang sedang dipilih */
|
||||||
|
selectedPoli: { type: [String, Number, null], default: null },
|
||||||
|
/** Nama dokter yang sedang dipilih */
|
||||||
|
selectedDoctor: { type: [String, null], default: null },
|
||||||
|
/** Tanggal yang diklik di kalender (opsional, sebagai default adhoc) */
|
||||||
|
clickedDate: { type: [Date, null], default: null },
|
||||||
|
/** Mode tampilan saat ini ('list' atau 'calendar') */
|
||||||
|
viewMode: { type: String, default: 'list' },
|
||||||
|
/** Daftar jadwal saat ini (untuk preview kalender) */
|
||||||
|
jadwalList: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save']);
|
||||||
|
|
||||||
|
/** Computed v-model proxy untuk dialog visibility */
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** State form tambah jadwal */
|
||||||
|
const form = reactive({
|
||||||
|
poliId: null,
|
||||||
|
dokter: null,
|
||||||
|
tipe: 'Rutin',
|
||||||
|
hari: '',
|
||||||
|
shifts: [{ jamMulai: '', jamSelesai: '' }],
|
||||||
|
adhocTanggal: '',
|
||||||
|
adhocShifts: [{ jamMulai: '', jamSelesai: '' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Computed daftar dokter berdasarkan poli yang dipilih */
|
||||||
|
const availableDoctors = computed(() => {
|
||||||
|
if (!form.poliId) return [];
|
||||||
|
const clinic = props.clinicList.find(c => c.id === form.poliId);
|
||||||
|
return clinic?.doctors || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Error validasi form */
|
||||||
|
const formErrors = ref({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tanggal ke string YYYY-MM-DD.
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const toDateStr = (date) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
const tzOffset = d.getTimezoneOffset() * 60000;
|
||||||
|
return new Date(d.getTime() - tzOffset).toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reset form saat dialog dibuka */
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.viewMode === 'calendar') {
|
||||||
|
form.poliId = props.selectedPoli || null;
|
||||||
|
form.dokter = props.selectedDoctor || null;
|
||||||
|
} else {
|
||||||
|
form.poliId = null;
|
||||||
|
form.dokter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.tipe = 'Rutin';
|
||||||
|
form.hari = '';
|
||||||
|
form.shifts = [{ jamMulai: '', jamSelesai: '' }];
|
||||||
|
formErrors.value = {};
|
||||||
|
|
||||||
|
if (props.clickedDate) {
|
||||||
|
const dateStr = toDateStr(props.clickedDate);
|
||||||
|
form.adhocTanggal = dateStr;
|
||||||
|
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
|
||||||
|
} else {
|
||||||
|
const dateStr = toDateStr(new Date());
|
||||||
|
form.adhocTanggal = dateStr;
|
||||||
|
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validasi form sebelum simpan.
|
||||||
|
* @returns {boolean} true jika valid
|
||||||
|
*/
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors = {};
|
||||||
|
if (!form.poliId) errors.poli = 'Pilih poli terlebih dahulu';
|
||||||
|
if (!form.dokter) errors.dokter = 'Pilih dokter terlebih dahulu';
|
||||||
|
|
||||||
|
if (form.tipe === 'Rutin') {
|
||||||
|
if (!form.hari) errors.hari = 'Pilih hari kerja';
|
||||||
|
form.shifts.forEach((shift, idx) => {
|
||||||
|
if (!shift.jamMulai) errors[`shift_${idx}_jamMulai`] = 'Pilih jam mulai';
|
||||||
|
if (!shift.jamSelesai) errors[`shift_${idx}_jamSelesai`] = 'Pilih jam selesai';
|
||||||
|
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
|
||||||
|
errors[`shift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!form.adhocTanggal) errors.adhocTanggal = 'Pilih tanggal';
|
||||||
|
form.adhocShifts.forEach((shift, idx) => {
|
||||||
|
if (!shift.jamMulai) errors[`adhocShift_${idx}_jamMulai`] = 'Pilih jam mulai';
|
||||||
|
if (!shift.jamSelesai) errors[`adhocShift_${idx}_jamSelesai`] = 'Pilih jam selesai';
|
||||||
|
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
|
||||||
|
errors[`adhocShift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
formErrors.value = errors;
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menangani submit form — validasi lalu emit data ke parent.
|
||||||
|
*/
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
const poliName = props.clinicList.find(c => c.id === form.poliId)?.title ?? '';
|
||||||
|
const emittedData = [];
|
||||||
|
|
||||||
|
if (form.tipe === 'Rutin') {
|
||||||
|
form.shifts.forEach((shift, index) => {
|
||||||
|
emittedData.push({
|
||||||
|
id: Date.now() + index,
|
||||||
|
poliId: form.poliId,
|
||||||
|
poliName,
|
||||||
|
dokter: form.dokter,
|
||||||
|
tipe: 'Rutin',
|
||||||
|
hari: [form.hari],
|
||||||
|
jamMulai: shift.jamMulai || null,
|
||||||
|
jamSelesai: shift.jamSelesai || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.adhocShifts.forEach((shift, index) => {
|
||||||
|
emittedData.push({
|
||||||
|
id: Date.now() + index,
|
||||||
|
poliId: form.poliId,
|
||||||
|
poliName,
|
||||||
|
dokter: form.dokter,
|
||||||
|
tipe: 'Adhoc',
|
||||||
|
mulai: `${form.adhocTanggal}T${shift.jamMulai}:00`,
|
||||||
|
selesai: `${form.adhocTanggal}T${shift.jamSelesai}:00`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('save', emittedData);
|
||||||
|
dialogOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
import MiniCalendarPreview from './MiniCalendarPreview.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog v-model="dialogOpen" max-width="520px" persistent>
|
||||||
|
<v-card rounded="lg" class="pa-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<v-card-title class="bg-primary text-white pa-5">
|
||||||
|
<div class="d-flex justify-space-between align-center w-100">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<v-icon>mdi-calendar-plus</v-icon>
|
||||||
|
<span class="text-h6 font-weight-bold">Tambah Jadwal Dokter</span>
|
||||||
|
</div>
|
||||||
|
<v-btn icon="mdi-close" variant="text" size="small" class="text-white"
|
||||||
|
@click="dialogOpen = false" />
|
||||||
|
</div>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-6">
|
||||||
|
|
||||||
|
<!-- Tipe Jadwal -->
|
||||||
|
<div class="text-caption text-grey-darken-1 mb-2 font-weight-medium">Tipe Jadwal</div>
|
||||||
|
<v-btn-toggle v-model="form.tipe" mandatory color="primary" variant="outlined" class="mb-5 w-100"
|
||||||
|
divided>
|
||||||
|
<v-btn value="Rutin" class="flex-grow-1">
|
||||||
|
<v-icon start>mdi-calendar-sync</v-icon>
|
||||||
|
Rutin
|
||||||
|
</v-btn>
|
||||||
|
<v-btn value="Adhoc" class="flex-grow-1">
|
||||||
|
<v-icon start>mdi-calendar-clock</v-icon>
|
||||||
|
Adhoc
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
|
||||||
|
<!-- Info poli & dokter -->
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col cols="12" sm="12">
|
||||||
|
<div class="text-caption text-grey mb-1">Poli / Klinik <span class="text-error">*</span></div>
|
||||||
|
<v-select v-model="form.poliId" :items="clinicList" item-title="title" item-value="id"
|
||||||
|
density="compact" variant="outlined" hide-details="auto" placeholder="Pilih Poli"
|
||||||
|
prepend-inner-icon="mdi-hospital-building" @update:model-value="form.dokter = null"
|
||||||
|
:error-messages="formErrors.poli" />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="12">
|
||||||
|
<div class="text-caption text-grey mb-1">Dokter <span class="text-error">*</span></div>
|
||||||
|
<v-select v-model="form.dokter" :items="availableDoctors" density="compact" variant="outlined"
|
||||||
|
hide-details="auto" placeholder="Pilih Dokter" prepend-inner-icon="mdi-doctor"
|
||||||
|
:disabled="!form.poliId" :error-messages="formErrors.dokter" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- ── Form Rutin ──────────────────────────────── -->
|
||||||
|
<template v-if="form.tipe === 'Rutin'">
|
||||||
|
<div class="text-caption text-grey-darken-1 mb-3 font-weight-medium">Pilih Hari Kerja</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-1">
|
||||||
|
<v-chip class="mr-1" v-for="day in DAYS_OF_WEEK" :key="day"
|
||||||
|
:color="form.hari === day ? 'primary' : 'primary'"
|
||||||
|
:variant="form.hari === day ? 'flat' : 'outlined'" size="small" style="cursor:pointer;"
|
||||||
|
@click="form.hari = day">
|
||||||
|
{{ day }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div v-if="formErrors.hari" class="text-caption text-error mb-4">{{ formErrors.hari }}</div>
|
||||||
|
|
||||||
|
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
|
||||||
|
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
|
||||||
|
:dokter="form.dokter" />
|
||||||
|
|
||||||
|
<!-- Jam required -->
|
||||||
|
<div v-for="(shift, index) in form.shifts" :key="index" class="d-flex align-center gap-4 mt-4">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="6">
|
||||||
|
<div class="text-caption text-grey mb-1">Jam Mulai</div>
|
||||||
|
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
|
||||||
|
density="compact" hide-details required
|
||||||
|
:error-messages="formErrors[`shift_${index}_jamMulai`]" />
|
||||||
|
<div v-if="formErrors[`shift_${index}_jamMulai`]"
|
||||||
|
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamMulai`] }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<div class="text-caption text-grey mb-1">Jam Selesai</div>
|
||||||
|
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
|
||||||
|
density="compact" hide-details required
|
||||||
|
:error-messages="formErrors[`shift_${index}_jamSelesai`]" />
|
||||||
|
<div v-if="formErrors[`shift_${index}_jamSelesai`]"
|
||||||
|
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamSelesai`]
|
||||||
|
}}</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5" v-if="form.shifts.length > 1">
|
||||||
|
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
|
||||||
|
@click="form.shifts.splice(index, 1)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<v-btn variant="outlined" color="primary" size="small" class="text-none"
|
||||||
|
@click="form.shifts.push({ jamMulai: '', jamSelesai: '' })">
|
||||||
|
+ Tambah Jadwal {{ form.hari ? `Hari ${form.hari}` : '' }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Form Adhoc ──────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
<v-row class="mb-2">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field v-model="form.adhocTanggal" label="Tanggal" type="date" variant="outlined"
|
||||||
|
density="compact" prepend-inner-icon="mdi-calendar" hide-details="auto"
|
||||||
|
:error-messages="formErrors.adhocTanggal" />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
|
||||||
|
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
|
||||||
|
:dokter="form.dokter" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<div v-for="(shift, index) in form.adhocShifts" :key="index" class="d-flex align-center gap-4 mt-2">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="6">
|
||||||
|
<div class="text-caption text-grey mb-1">Jam Mulai</div>
|
||||||
|
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
|
||||||
|
density="compact" hide-details required
|
||||||
|
:error-messages="formErrors[`adhocShift_${index}_jamMulai`]" />
|
||||||
|
<div v-if="formErrors[`adhocShift_${index}_jamMulai`]"
|
||||||
|
class="text-caption text-error mt-1">{{
|
||||||
|
formErrors[`adhocShift_${index}_jamMulai`] }}</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<div class="text-caption text-grey mb-1">Jam Selesai</div>
|
||||||
|
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
|
||||||
|
density="compact" hide-details required
|
||||||
|
:error-messages="formErrors[`adhocShift_${index}_jamSelesai`]" />
|
||||||
|
<div v-if="formErrors[`adhocShift_${index}_jamSelesai`]"
|
||||||
|
class="text-caption text-error mt-1">{{
|
||||||
|
formErrors[`adhocShift_${index}_jamSelesai`] }}</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5" v-if="form.adhocShifts.length > 1">
|
||||||
|
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
|
||||||
|
@click="form.adhocShifts.splice(index, 1)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<v-btn variant="outlined" color="primary" size="small" class="text-none"
|
||||||
|
@click="form.adhocShifts.push({ jamMulai: '', jamSelesai: '' })">
|
||||||
|
+ Tambah Shift Adhoc
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-5 justify-end gap-2">
|
||||||
|
<v-btn variant="text" color="grey" @click="dialogOpen = false">Batal</v-btn>
|
||||||
|
<v-btn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="handleSave">
|
||||||
|
Simpan Jadwal
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* JadwalDokterCalendar — Mode tampilan kalender untuk jadwal dokter.
|
||||||
|
* Menampilkan filter poli/dokter dan v-calendar Vuetify dengan event
|
||||||
|
* jadwal rutin (primary) dan adhoc (secondary).
|
||||||
|
* Menerima data dari parent dan emit interaksi kembali ke parent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||||
|
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Daftar klinik/poli untuk filter */
|
||||||
|
clinicList: { type: Array, default: () => [] },
|
||||||
|
/** Daftar event kalender yang sudah di-generate oleh parent */
|
||||||
|
calendarEvents: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:selectedPoli',
|
||||||
|
'update:selectedDoctor',
|
||||||
|
'date-click',
|
||||||
|
'event-click',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Filter State (local, synced ke parent via emit) ──────────────────────
|
||||||
|
|
||||||
|
/** Poli yang dipilih di mode calendar */
|
||||||
|
const selectedPoli = defineModel('selectedPoli', { type: [String, Number, null], default: null });
|
||||||
|
|
||||||
|
/** Dokter yang dipilih di mode calendar */
|
||||||
|
const selectedDoctor = defineModel('selectedDoctor', { type: [String, null], default: null });
|
||||||
|
|
||||||
|
|
||||||
|
const selectedElement = ref(null)
|
||||||
|
const selectedOpen = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daftar dokter berdasarkan poli yang dipilih.
|
||||||
|
* @returns {string[]} Kosong jika poli belum dipilih
|
||||||
|
*/
|
||||||
|
const doctorList = computed(() => {
|
||||||
|
if (!selectedPoli.value) return [];
|
||||||
|
const clinic = props.clinicList.find(c => c.id === selectedPoli.value);
|
||||||
|
return clinic?.doctors ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Reset dokter saat poli berubah */
|
||||||
|
watch(selectedPoli, () => { selectedDoctor.value = null; });
|
||||||
|
|
||||||
|
// ── Navigasi Kalender ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isDailyEventsOpen = ref(false);
|
||||||
|
const dailyEvents = ref([]);
|
||||||
|
const selectedDateFormatted = ref('');
|
||||||
|
|
||||||
|
|
||||||
|
/** Tanggal referensi untuk navigasi bulan */
|
||||||
|
const viewDate = ref(new Date());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key dinamis untuk v-calendar. Vuetify v-calendar punya internal navigation
|
||||||
|
* state sendiri — mengubah :model-value saja tidak cukup. Dengan mengganti :key,
|
||||||
|
* kita paksa komponen re-mount sehingga bulan yang tampil ikut berubah.
|
||||||
|
*/
|
||||||
|
const calendarKey = computed(() =>
|
||||||
|
`cal-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Judul bulan & tahun aktif */
|
||||||
|
const calendarTitle = computed(() =>
|
||||||
|
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Navigasi ke bulan sebelumnya */
|
||||||
|
const prevMonth = () => {
|
||||||
|
const d = new Date(viewDate.value);
|
||||||
|
d.setMonth(d.getMonth() - 1);
|
||||||
|
viewDate.value = new Date(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Navigasi ke bulan berikutnya */
|
||||||
|
const nextMonth = () => {
|
||||||
|
const d = new Date(viewDate.value);
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
viewDate.value = new Date(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menangani klik pada tanggal di kalender.
|
||||||
|
* @param {Date|string|object} info - Payload dari @click:date
|
||||||
|
*/
|
||||||
|
const handleDateClick = (info, { date }) => {
|
||||||
|
emit('date-click', date);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menangani klik pada event di kalender.
|
||||||
|
* @param {object} payload - Payload dari @click:event v-calendar
|
||||||
|
*/
|
||||||
|
const handleEventClick = (nativeEvent, { event }) => {
|
||||||
|
const ev = event;
|
||||||
|
if (!ev?.extendedProps) return;
|
||||||
|
const d = ev.start instanceof Date ? ev.start : new Date(ev.start);
|
||||||
|
emit('event-click', {
|
||||||
|
...ev.extendedProps,
|
||||||
|
tanggal: d.toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Menangani klik "+X lainnya" pada kalender.
|
||||||
|
* @param {Date|string|object} info - Payload dari @click:more
|
||||||
|
*/
|
||||||
|
const handleMoreClick = (nativeEvent, { date }) => {
|
||||||
|
selectedDateFormatted.value = date;
|
||||||
|
console.log("date", date)
|
||||||
|
console.log("calendarEvents.value", props.calendarEvents)
|
||||||
|
dailyEvents.value = props.calendarEvents.filter(e =>
|
||||||
|
e.start.toISOString().split("T")[0] === selectedDateFormatted.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
// selectedEvent.value = event
|
||||||
|
selectedElement.value = nativeEvent.target
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => selectedOpen.value = true))
|
||||||
|
}
|
||||||
|
if (selectedOpen.value) {
|
||||||
|
selectedOpen.value = false
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => open()))
|
||||||
|
} else {
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
nativeEvent.stopPropagation()
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menangani klik pada event di dalam dialog harian.
|
||||||
|
*/
|
||||||
|
const handleDailyEventClick = (ev) => {
|
||||||
|
isDailyEventsOpen.value = false;
|
||||||
|
const payload = { event: ev };
|
||||||
|
handleEventClick(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- ── Filter Card ─────────────────────────────────────── -->
|
||||||
|
<v-row class="mb-2">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="pa-6" color="white">
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<v-icon color="primary" class="mr-2">mdi-filter-variant</v-icon>
|
||||||
|
<h3 class="text-subtitle-1 font-weight-bold text-primary">FILTER JADWAL</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-select v-model="selectedPoli" :items="clinicList" item-title="title" item-value="id"
|
||||||
|
label="Pilih Poli / Klinik" variant="outlined" density="compact" clearable
|
||||||
|
prepend-inner-icon="mdi-hospital-building" hide-details />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-select v-model="selectedDoctor" :items="doctorList" label="Pilih Dokter"
|
||||||
|
variant="outlined" density="compact" clearable prepend-inner-icon="mdi-doctor"
|
||||||
|
:disabled="!selectedPoli" hide-details
|
||||||
|
:placeholder="selectedPoli ? 'Pilih dokter...' : 'Pilih poli terlebih dahulu'" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<div v-if="selectedPoli || selectedDoctor" class="mt-4 d-flex align-center gap-2 flex-wrap">
|
||||||
|
<span class="text-caption text-grey-darken-1">Filter aktif:</span>
|
||||||
|
<v-chip v-if="selectedPoli" size="small" color="primary" variant="tonal" closable
|
||||||
|
@click:close="selectedPoli = null">
|
||||||
|
<v-icon start size="x-small">mdi-hospital-building</v-icon>
|
||||||
|
{{clinicList.find(c => c.id === selectedPoli)?.title}}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip v-if="selectedDoctor" size="small" color="secondary" variant="tonal" closable
|
||||||
|
@click:close="selectedDoctor = null">
|
||||||
|
<v-icon start size="x-small">mdi-doctor</v-icon>
|
||||||
|
{{ selectedDoctor }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- ── Calendar Card ───────────────────────────────────── -->
|
||||||
|
<v-row v-if="selectedDoctor && selectedPoli">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card elevation="2" rounded="lg" color="white">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="d-flex align-center justify-space-between px-6 pt-5 pb-4">
|
||||||
|
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-btn icon="mdi-chevron-left" variant="text" size="small" @click="prevMonth" />
|
||||||
|
<v-btn icon="mdi-chevron-right" variant="text" size="small" @click="nextMonth" />
|
||||||
|
<span class="text-body-1 font-weight-bold" style="min-width:150px;text-align:center;">
|
||||||
|
{{ calendarTitle }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<div class="d-flex align-center gap-1">
|
||||||
|
<span class="legend-dot bg-primary"></span>
|
||||||
|
<span class="text-caption ml-1">Rutin</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center gap-1 ml-1">
|
||||||
|
<span class="legend-dot bg-secondary"></span>
|
||||||
|
<span class="text-caption ml-1">Adhoc</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<!-- v-calendar Vuetify -->
|
||||||
|
<v-calendar :interval-height="100" locale="id" :key="calendarKey" :model-value="viewDate"
|
||||||
|
:events="calendarEvents" view-mode="month" event-more-text="{0} lainnya" class="jadwal-calendar"
|
||||||
|
@click:date="handleDateClick" @click:event="handleEventClick" @click:more="handleMoreClick" />
|
||||||
|
<v-menu v-model="selectedOpen" :activator="selectedElement" :close-on-content-click="false"
|
||||||
|
location="end">
|
||||||
|
<v-card color="grey-lighten-4" min-width="350px" flat>
|
||||||
|
<v-toolbar color="primary" dark>
|
||||||
|
<v-btn icon>
|
||||||
|
<v-icon>mdi-calendar</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-toolbar-title>Jadwal Lainnya</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-card-text style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<v-list v-if="dailyEvents.length > 0" lines="two" bg-color="transparent">
|
||||||
|
<v-list-item v-for="(ev, idx) in dailyEvents" :key="idx" class="mb-3 rounded border"
|
||||||
|
:class="ev.color === 'primary' ? 'border-primary' : 'border-secondary'"
|
||||||
|
@click="handleDailyEventClick(ev)" hover>
|
||||||
|
<v-list-item-title class="font-weight-bold">
|
||||||
|
<v-chip :color="ev.color" size="small" class="mr-2">{{
|
||||||
|
ev.extendedProps?.tipe }}</v-chip>
|
||||||
|
{{ ev.name }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="mt-1">
|
||||||
|
<v-icon size="x-small" class="mr-1">mdi-doctor</v-icon>{{
|
||||||
|
ev.extendedProps?.dokter }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
<v-icon size="x-small" class="mr-1">mdi-hospital-building</v-icon>{{
|
||||||
|
ev.extendedProps?.poli }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<div v-else class="text-center pa-6 text-grey">
|
||||||
|
Tidak ada jadwal tambahan.
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-card min-height="400px">
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="text-center pa-12 d-flex align-center justify-center" style="height: 400px;">
|
||||||
|
<div>
|
||||||
|
<v-icon size="48" color="grey-lighten-1"
|
||||||
|
class="mb-2">mdi-calendar-blank-outline</v-icon>
|
||||||
|
<div class="text-body-2 text-grey-lighten-1">Silahkan Pilih Poli dan Dokter terlebih
|
||||||
|
dahulu</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.v-calendar-weekly__day) {
|
||||||
|
height: 125px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jadwal-calendar {
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header hari */
|
||||||
|
:deep(.v-calendar-month__weekday) {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #283593;
|
||||||
|
background-color: #e8eaf6;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nomor tanggal */
|
||||||
|
:deep(.v-calendar-month__day-label) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #424242;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-calendar-month__day-label:hover) {
|
||||||
|
background-color: #e8eaf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hari ini */
|
||||||
|
:deep(.v-calendar-month__day--today .v-calendar-month__day-label) {
|
||||||
|
background-color: #1a237e;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event */
|
||||||
|
:deep(.v-calendar-month__event) {
|
||||||
|
font-size: 0.68rem !important;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-calendar-month__event:hover) {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* JadwalDokterList — Mode tampilan daftar (tabel) untuk jadwal dokter.
|
||||||
|
* Menampilkan filter (poli klinik, tanggal, nama dokter) dan tabel
|
||||||
|
* dengan kolom NO, POLI, NAMA DOKTER, JADWAL RUTIN, AKSI.
|
||||||
|
* Mode ini menjadi tampilan default halaman Jadwal Dokter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Daftar klinik/poli dari store */
|
||||||
|
clinicList: { type: Array, default: () => [] },
|
||||||
|
/** Daftar jadwal yang sudah ditambahkan */
|
||||||
|
jadwalList: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'add']);
|
||||||
|
|
||||||
|
// ── Filter State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Poli yang dipilih untuk filter */
|
||||||
|
const filterPoli = ref(null);
|
||||||
|
|
||||||
|
/** Tanggal yang dipilih untuk filter */
|
||||||
|
const filterDate = ref(null);
|
||||||
|
|
||||||
|
/** Keyword pencarian nama dokter */
|
||||||
|
const filterDokter = ref('');
|
||||||
|
|
||||||
|
// ── Pagination ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Halaman aktif saat ini */
|
||||||
|
const currentPage = ref(1);
|
||||||
|
|
||||||
|
/** Jumlah baris per halaman */
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
|
||||||
|
/** Opsi jumlah baris per halaman */
|
||||||
|
const perPageOptions = [5, 10, 25, 50];
|
||||||
|
|
||||||
|
// ── Mapping Hari ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DAY_MAP = {
|
||||||
|
0: 'Minggu', 1: 'Senin', 2: 'Selasa', 3: 'Rabu',
|
||||||
|
4: 'Kamis', 5: 'Jumat', 6: 'Sabtu'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Computed: Data Tabel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Membangun data tabel dari jadwalList, dikelompokkan per dokter + poli.
|
||||||
|
* Setiap baris menampilkan 1 dokter dengan ringkasan jadwal rutinnya.
|
||||||
|
* @returns {Array<{ id, poliName, poliId, dokter, jadwalRutin, raw }>}
|
||||||
|
*/
|
||||||
|
const tableData = computed(() => {
|
||||||
|
/** Gabungkan jadwal berdasarkan key unik dokter+poli */
|
||||||
|
const grouped = {};
|
||||||
|
|
||||||
|
props.jadwalList.forEach(j => {
|
||||||
|
const key = `${j.poliId}-${j.dokter}`;
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = {
|
||||||
|
id: j.id,
|
||||||
|
poliName: j.poliName,
|
||||||
|
poliId: j.poliId,
|
||||||
|
dokter: j.dokter,
|
||||||
|
hariSet: new Set(),
|
||||||
|
jamMulai: null,
|
||||||
|
jamSelesai: null,
|
||||||
|
raw: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
grouped[key].raw.push(j);
|
||||||
|
|
||||||
|
if (j.tipe === 'Rutin' && j.hari) {
|
||||||
|
j.hari.forEach(h => grouped[key].hariSet.add(h));
|
||||||
|
if (j.jamMulai) grouped[key].jamMulai = j.jamMulai;
|
||||||
|
if (j.jamSelesai) grouped[key].jamSelesai = j.jamSelesai;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(grouped).map(g => {
|
||||||
|
const hariArr = Array.from(g.hariSet);
|
||||||
|
/** Format jadwal rutin: "Senin - Kamis\n08:00 - 14:00 WIB" */
|
||||||
|
let jadwalRutin = '';
|
||||||
|
if (hariArr.length > 0) {
|
||||||
|
jadwalRutin = hariArr.join(', ');
|
||||||
|
if (g.jamMulai && g.jamSelesai) {
|
||||||
|
jadwalRutin += `\n${g.jamMulai} - ${g.jamSelesai} WIB`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
jadwalRutin = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: g.id,
|
||||||
|
poliName: g.poliName,
|
||||||
|
poliId: g.poliId,
|
||||||
|
dokter: g.dokter,
|
||||||
|
jadwalRutin,
|
||||||
|
raw: g.raw,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data tabel yang sudah difilter berdasarkan poli, tanggal, dan keyword nama dokter.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
let data = tableData.value;
|
||||||
|
|
||||||
|
// Filter poli
|
||||||
|
if (filterPoli.value) {
|
||||||
|
data = data.filter(d => d.poliId === filterPoli.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tanggal — cocokkan dengan hari dari jadwal rutin
|
||||||
|
if (filterDate.value) {
|
||||||
|
const date = new Date(filterDate.value);
|
||||||
|
const dayName = DAY_MAP[date.getDay()];
|
||||||
|
data = data.filter(d =>
|
||||||
|
d.raw.some(j => j.tipe === 'Rutin' && j.hari?.includes(dayName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter nama dokter
|
||||||
|
if (filterDokter.value) {
|
||||||
|
const keyword = filterDokter.value.toLowerCase();
|
||||||
|
data = data.filter(d => d.dokter.toLowerCase().includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Total data setelah filter */
|
||||||
|
const totalItems = computed(() => filteredData.value.length);
|
||||||
|
|
||||||
|
/** Total halaman */
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value)));
|
||||||
|
|
||||||
|
/** Data untuk halaman aktif (sliced) */
|
||||||
|
const paginatedData = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||||
|
return filteredData.value.slice(start, start + itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Label info pagination: "Menampilkan 1-10 dari 124 dokter spesialis" */
|
||||||
|
const paginationLabel = computed(() => {
|
||||||
|
if (totalItems.value === 0) return 'Tidak ada data';
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage.value + 1;
|
||||||
|
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
|
||||||
|
return `Menampilkan ${start}-${end} dari ${totalItems.value} dokter spesialis`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Reset halaman saat filter berubah */
|
||||||
|
watch([filterPoli, filterDate, filterDokter], () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigasi ke halaman tertentu.
|
||||||
|
* @param {number} page
|
||||||
|
*/
|
||||||
|
const goToPage = (page) => {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
currentPage.value = page;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- ── Filter Card ─────────────────────────────────────── -->
|
||||||
|
<v-card class="pa-5 mb-6" elevation="2" rounded="lg" color="white">
|
||||||
|
<v-row align="center">
|
||||||
|
<v-col cols="12" sm="4">
|
||||||
|
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Poli Klinik</div>
|
||||||
|
<v-select v-model="filterPoli" :items="clinicList" item-title="title" item-value="id"
|
||||||
|
placeholder="Semua Poli" variant="outlined" density="compact" clearable
|
||||||
|
prepend-inner-icon="mdi-hospital-building" hide-details />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="4">
|
||||||
|
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Tanggal</div>
|
||||||
|
<v-text-field v-model="filterDate" type="date" placeholder="Pilih Tanggal" variant="outlined"
|
||||||
|
density="compact" clearable prepend-inner-icon="mdi-calendar" hide-details />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="4">
|
||||||
|
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Nama Dokter</div>
|
||||||
|
<v-text-field v-model="filterDokter" placeholder="Cari Nama Dokter..." variant="outlined"
|
||||||
|
density="compact" clearable prepend-inner-icon="mdi-account-search" hide-details />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- ── Tabel Jadwal ────────────────────────────────────── -->
|
||||||
|
<v-card elevation="2" rounded="lg" color="white">
|
||||||
|
<v-table class="jadwal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left" style="width: 60px;">NO</th>
|
||||||
|
<th class="text-left" style="width: 140px;">POLI</th>
|
||||||
|
<th class="text-left">NAMA DOKTER</th>
|
||||||
|
<th class="text-left" style="width: 200px;">JADWAL RUTIN</th>
|
||||||
|
<th class="text-center" style="width: 100px;">AKSI</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="paginatedData.length === 0">
|
||||||
|
<td colspan="5" class="text-center pa-8 text-grey">
|
||||||
|
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-calendar-blank-outline</v-icon>
|
||||||
|
<div class="text-body-2">Belum ada data jadwal dokter</div>
|
||||||
|
<div class="text-caption text-grey-lighten-1">Tambahkan jadwal dokter melalui mode
|
||||||
|
kalender</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(item, index) in paginatedData" :key="item.id" class="jadwal-row">
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-chip size="small" color="primary" variant="tonal">
|
||||||
|
{{ item.poliName }}
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2 font-weight-bold">
|
||||||
|
{{ item.dokter }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="text-body-2" style="white-space: pre-line; line-height: 1.5;">
|
||||||
|
{{ item.jadwalRutin }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-btn variant="outlined" size="small" color="primary" rounded="lg"
|
||||||
|
prepend-icon="mdi-pencil" @click="emit('edit', item)">
|
||||||
|
Edit
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
|
||||||
|
<!-- ── Pagination Footer ──────────────────────────── -->
|
||||||
|
<v-divider />
|
||||||
|
<div class="d-flex align-center justify-space-between px-4 py-3 pagination-footer">
|
||||||
|
<span class="text-caption text-grey-darken-1">{{ paginationLabel }}</span>
|
||||||
|
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<span class="text-caption text-grey-darken-1 mr-2">Baris per halaman:</span>
|
||||||
|
<v-select v-model="itemsPerPage" :items="perPageOptions" variant="plain" density="compact"
|
||||||
|
hide-details style="max-width: 70px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center gap-1">
|
||||||
|
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1" @click="goToPage(1)">
|
||||||
|
<v-icon size="16">mdi-page-first</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1"
|
||||||
|
@click="goToPage(currentPage - 1)">
|
||||||
|
<v-icon size="16">mdi-chevron-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
|
||||||
|
@click="goToPage(currentPage + 1)">
|
||||||
|
<v-icon size="16">mdi-chevron-right</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
|
||||||
|
@click="goToPage(totalPages)">
|
||||||
|
<v-icon size="16">mdi-page-last</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jadwal-table {
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jadwal-table thead th {
|
||||||
|
background-color: #f5f6fa !important;
|
||||||
|
color: #5a607f !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 2px solid #e8eaf0 !important;
|
||||||
|
padding: 14px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jadwal-row {
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jadwal-row:hover {
|
||||||
|
background-color: #f8f9fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jadwal-row td {
|
||||||
|
padding: 16px !important;
|
||||||
|
border-bottom: 1px solid #f0f1f5 !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pagination-footer {
|
||||||
|
background-color: #fafbfc;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tipe: { type: String, default: 'Rutin' },
|
||||||
|
hari: { type: String, default: '' },
|
||||||
|
adhocTanggal: { type: String, default: '' },
|
||||||
|
jadwalList: { type: Array, default: () => [] },
|
||||||
|
poliId: { type: [String, Number, null], default: null },
|
||||||
|
dokter: { type: String, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||||
|
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_MAP = {
|
||||||
|
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
|
||||||
|
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewDate = ref(new Date());
|
||||||
|
|
||||||
|
const calendarKey = computed(() =>
|
||||||
|
`cal-mini-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const calendarTitle = computed(() =>
|
||||||
|
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
const d = new Date(viewDate.value);
|
||||||
|
d.setMonth(d.getMonth() - 1);
|
||||||
|
viewDate.value = new Date(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
const d = new Date(viewDate.value);
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
viewDate.value = new Date(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDateStr = (date) => {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarEvents = computed(() => {
|
||||||
|
const events = [];
|
||||||
|
const year = viewDate.value.getFullYear();
|
||||||
|
const month = viewDate.value.getMonth();
|
||||||
|
|
||||||
|
// 1. Generate existing events for the selected doctor
|
||||||
|
const filtered = props.jadwalList.filter(j => {
|
||||||
|
if (props.poliId && j.poliId !== props.poliId) return false;
|
||||||
|
if (props.dokter && j.dokter !== props.dokter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.forEach(jadwal => {
|
||||||
|
if (jadwal.tipe === 'Rutin') {
|
||||||
|
for (let mOffset = -1; mOffset <= 1; mOffset++) {
|
||||||
|
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
|
||||||
|
const tMonth = (month + mOffset + 12) % 12;
|
||||||
|
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const date = new Date(tYear, tMonth, d);
|
||||||
|
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
|
||||||
|
|
||||||
|
let jadwalHariArray = [];
|
||||||
|
if (Array.isArray(jadwal.hari)) {
|
||||||
|
jadwalHariArray = jadwal.hari;
|
||||||
|
} else if (typeof jadwal.hari === 'string') {
|
||||||
|
jadwalHariArray = [jadwal.hari];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jadwalHariArray.includes(dayName)) continue;
|
||||||
|
|
||||||
|
const dateStr = toDateStr(date);
|
||||||
|
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
|
||||||
|
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
|
||||||
|
: '';
|
||||||
|
events.push({
|
||||||
|
name: `${jamLabel}`,
|
||||||
|
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
|
||||||
|
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
|
||||||
|
color: 'primary',
|
||||||
|
allDay: !jadwal.jamMulai,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
|
||||||
|
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
|
||||||
|
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
|
||||||
|
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
|
||||||
|
: '';
|
||||||
|
events.push({
|
||||||
|
name: `${adhocJamLabel}`,
|
||||||
|
start: new Date(jadwal.mulai),
|
||||||
|
end: new Date(jadwal.selesai),
|
||||||
|
color: 'secondary',
|
||||||
|
allDay: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarRef = ref(null);
|
||||||
|
|
||||||
|
const updateHighlight = () => {
|
||||||
|
// Wait for calendar to fully render internally
|
||||||
|
setTimeout(() => {
|
||||||
|
const calendarEl = calendarRef.value?.$el || document.querySelector('.mini-calendar');
|
||||||
|
if (!calendarEl) return;
|
||||||
|
|
||||||
|
// Remove previous highlights
|
||||||
|
const previous = calendarEl.querySelectorAll('.adhoc-highlight, .rutin-highlight');
|
||||||
|
previous.forEach(el => {
|
||||||
|
el.classList.remove('adhoc-highlight', 'rutin-highlight');
|
||||||
|
el.style.border = '';
|
||||||
|
el.style.borderRadius = '';
|
||||||
|
el.style.color = '';
|
||||||
|
el.style.fontWeight = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDays = calendarEl.querySelectorAll('.v-calendar-weekly__day');
|
||||||
|
if (allDays.length === 0) return;
|
||||||
|
|
||||||
|
if (props.tipe === 'Rutin' && props.hari) {
|
||||||
|
const targetCol = DAY_MAP[props.hari];
|
||||||
|
allDays.forEach((dayEl, index) => {
|
||||||
|
// Check if index matches column and it's not from adjacent month
|
||||||
|
if (index % 7 === targetCol && !dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
|
||||||
|
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
|
||||||
|
if (labelBtn) {
|
||||||
|
labelBtn.classList.add('rutin-highlight');
|
||||||
|
// Fallback inline styles in case CSS scoped issue occurs
|
||||||
|
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
|
||||||
|
labelBtn.style.borderRadius = '50%';
|
||||||
|
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
|
||||||
|
labelBtn.style.fontWeight = 'bold';
|
||||||
|
labelBtn.style.width = '28px';
|
||||||
|
labelBtn.style.height = '28px';
|
||||||
|
labelBtn.style.display = 'flex';
|
||||||
|
labelBtn.style.alignItems = 'center';
|
||||||
|
labelBtn.style.justifyContent = 'center';
|
||||||
|
labelBtn.style.margin = '2px auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (props.tipe === 'Adhoc' && props.adhocTanggal) {
|
||||||
|
const adhocDate = new Date(props.adhocTanggal);
|
||||||
|
if (adhocDate.getMonth() !== viewDate.value.getMonth() || adhocDate.getFullYear() !== viewDate.value.getFullYear()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayToHighlight = adhocDate.getDate().toString();
|
||||||
|
allDays.forEach(dayEl => {
|
||||||
|
if (!dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
|
||||||
|
const labelTextEl = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn__content, .v-calendar-weekly__day-label');
|
||||||
|
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
|
||||||
|
if (labelTextEl && labelTextEl.textContent.trim() === dayToHighlight) {
|
||||||
|
labelBtn.classList.add('adhoc-highlight');
|
||||||
|
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
|
||||||
|
labelBtn.style.borderRadius = '50%';
|
||||||
|
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
|
||||||
|
labelBtn.style.fontWeight = 'bold';
|
||||||
|
labelBtn.style.width = '28px';
|
||||||
|
labelBtn.style.height = '28px';
|
||||||
|
labelBtn.style.display = 'flex';
|
||||||
|
labelBtn.style.alignItems = 'center';
|
||||||
|
labelBtn.style.justifyContent = 'center';
|
||||||
|
labelBtn.style.margin = '2px auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
import { watch, nextTick } from 'vue';
|
||||||
|
watch([() => props.adhocTanggal, () => props.hari, () => props.tipe, viewDate], () => {
|
||||||
|
nextTick(() => updateHighlight());
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card class="mt-4 border border-grey-lighten-3 rounded-lg" elevation="0">
|
||||||
|
<div class="d-flex align-center justify-space-between px-4 pt-3 pb-2">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-btn icon="mdi-chevron-left" variant="text" size="x-small" @click="prevMonth" />
|
||||||
|
<v-btn icon="mdi-chevron-right" variant="text" size="x-small" @click="nextMonth" />
|
||||||
|
<span class="text-caption font-weight-bold mx-2" style="min-width:100px;text-align:center;">
|
||||||
|
{{ calendarTitle }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- We use a custom class to inject CSS for highlighted days if they have preview event -->
|
||||||
|
<v-calendar ref="calendarRef" :interval-height="40" locale="id" :key="calendarKey" :model-value="viewDate"
|
||||||
|
:events="calendarEvents" view-mode="month"
|
||||||
|
:class="['mini-calendar', props.tipe === 'Rutin' && props.hari ? 'preview-hari-' + props.hari : '']" />
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.v-calendar-weekly__day) {
|
||||||
|
height: 70px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar {
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header hari */
|
||||||
|
:deep(.v-calendar-weekly__weekday) {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #555;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nomor tanggal */
|
||||||
|
:deep(.v-calendar-weekly__day-label) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #424242;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lingkaran Preview via JS Class */
|
||||||
|
:deep(.rutin-highlight),
|
||||||
|
:deep(.adhoc-highlight) {
|
||||||
|
border: 2px solid rgb(var(--v-theme-primary)) !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event styles */
|
||||||
|
:deep(.v-calendar-weekly__event) {
|
||||||
|
font-size: 0.6rem !important;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 0 4px !important;
|
||||||
|
margin-bottom: 2px !important;
|
||||||
|
min-height: 16px !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 A"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,156 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="internalModel" max-width="500px">
|
||||||
|
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
|
||||||
|
<!-- Solid Blue Header -->
|
||||||
|
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
|
||||||
|
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
|
||||||
|
</div>
|
||||||
|
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="dialog-content-premium pa-6 text-center">
|
||||||
|
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
|
||||||
|
Antrean Belum Dibuat
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-1 mb-6">
|
||||||
|
Tiket antrean ruang atau penunjang <strong>belum dibuat</strong> untuk pasien ini.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
|
||||||
|
<div class="comparison-item current">
|
||||||
|
<div class="item-label text-center">PASIEN AKTIF</div>
|
||||||
|
<div class="item-card">
|
||||||
|
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
|
||||||
|
<div class="item-status">Sedang Diproses</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-2 text-neutral-600">
|
||||||
|
Apakah Anda yakin ingin menyelesaikan pelayanan untuk pasien ini?
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-4 bg-light justify-center">
|
||||||
|
<v-btn
|
||||||
|
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
|
||||||
|
variant="outlined"
|
||||||
|
@click="internalModel = false"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="px-8 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
|
||||||
|
variant="flat"
|
||||||
|
@click="confirm"
|
||||||
|
size="large"
|
||||||
|
elevation="2"
|
||||||
|
>
|
||||||
|
Ya, Selesaikan
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
patient: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||||
|
|
||||||
|
const internalModel = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
emit('confirm');
|
||||||
|
internalModel.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-card {
|
||||||
|
/* Remove border to prevent white outline around blue header */
|
||||||
|
}
|
||||||
|
.bg-light {
|
||||||
|
background-color: var(--color-neutral-50) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-neutral-400);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger-700 {
|
||||||
|
color: var(--color-danger-700) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-batal {
|
||||||
|
border-color: var(--color-neutral-300) !important;
|
||||||
|
color: var(--color-neutral-700) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background-color: var(--color-primary-600) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="current-patient-card" elevation="0">
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="section-header mb-3">
|
||||||
|
<div class="section-label">SEDANG DIPROSES</div>
|
||||||
|
<v-btn
|
||||||
|
v-if="patient"
|
||||||
|
class="call-button text-white"
|
||||||
|
color="primary-500"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('call')"
|
||||||
|
>
|
||||||
|
<v-icon start size="18">mdi-bullhorn</v-icon>
|
||||||
|
Panggil
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
|
||||||
|
<div class="patient-header">
|
||||||
|
<div class="patient-number-wrapper">
|
||||||
|
<div class="patient-number">{{ patient.noAntrian.split(" |")[0] }}</div>
|
||||||
|
<div
|
||||||
|
v-if="isFastTrack"
|
||||||
|
class="fast-track-icon-wrapper"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
color="#E53935"
|
||||||
|
size="18"
|
||||||
|
class="fast-track-icon"
|
||||||
|
>
|
||||||
|
mdi-flash
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-icons">
|
||||||
|
<v-btn
|
||||||
|
class="action-icon-btn text-white"
|
||||||
|
color="secondary-600"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('change-klinik')"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-swap-horizontal</v-icon>
|
||||||
|
<span class="btn-label">{{ changeButtonText }}</span>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="action-icon-btn text-white"
|
||||||
|
color="primary-600"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('action', 'terlambat')"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-clock-alert</v-icon>
|
||||||
|
<span class="btn-label">Terlambat</span>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="action-icon-btn text-white"
|
||||||
|
color="danger-600"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('action', 'pending')"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-pause</v-icon>
|
||||||
|
<span class="btn-label">Pending</span>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="action-icon-btn text-white"
|
||||||
|
color="success-600"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('action', 'check-in')"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-check</v-icon>
|
||||||
|
<span class="btn-label">Selesai</span>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="patient-info-text">
|
||||||
|
<div class="patient-barcode-rm d-flex align-center">
|
||||||
|
<div class="patient-barcode">{{ patient.barcode }}</div>
|
||||||
|
<v-chip v-if="patient.noRM" size="x-small" color="primary-600" variant="flat" class="text-white ml-2">
|
||||||
|
RM: {{ patient.noRM }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="patient-klinik-pembayaran">{{ patient.klinik }} | {{ patient.pembayaran }}</div>
|
||||||
|
<div v-if="patient.referencePatient" class="patient-reference d-flex align-center gap-1">
|
||||||
|
<v-icon size="13" color="primary-600">mdi-link</v-icon>
|
||||||
|
<span class="reference-label">Ref. Loket:</span>
|
||||||
|
<span class="reference-value text-primary-600">{{ patient.referencePatient }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="create-queue-buttons mt-4">
|
||||||
|
<div class="create-buttons-container">
|
||||||
|
<v-btn
|
||||||
|
class="create-btn-ruang py-6 text-white"
|
||||||
|
color="primary-600"
|
||||||
|
:disabled="!patient"
|
||||||
|
@click="$emit('open-klinik-ruang')"
|
||||||
|
>
|
||||||
|
<v-icon size="20" class="mr-2">mdi-door</v-icon>
|
||||||
|
Buat Antrean Ruang
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="create-btn-penunjang py-6 text-white"
|
||||||
|
color="secondary-600"
|
||||||
|
:disabled="!patient"
|
||||||
|
@click="$emit('open-penunjang')"
|
||||||
|
>
|
||||||
|
<v-icon size="20" class="mr-2">mdi-clipboard-pulse</v-icon>
|
||||||
|
Buat Antrean Penunjang
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFastTrack = computed(() => {
|
||||||
|
if (!props.patient) return false;
|
||||||
|
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
|
||||||
|
return fastTrack === 'YA';
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-ruang', 'open-penunjang']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.current-patient-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-neutral-500);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-button {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-details {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-details-primary {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-primary-100) 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-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-number-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-number {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fast-track-icon-wrapper {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(239, 83, 80, 0.15);
|
||||||
|
border: 2px solid rgba(229, 57, 53, 0.4);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fast-track-icon-wrapper:hover {
|
||||||
|
background: #E53935;
|
||||||
|
border-color: #E53935;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(229, 57, 53, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fast-track-icon-wrapper .fast-track-icon {
|
||||||
|
color: #E53935 !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fast-track-icon-wrapper:hover .fast-track-icon {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-btn {
|
||||||
|
min-width: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 32px !important;
|
||||||
|
padding: 0 12px !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-transform: none !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-info-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-barcode {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-klinik-pembayaran {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-reference {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-queue-buttons {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-buttons-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-queue-buttons .v-btn {
|
||||||
|
flex: 1;
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 8px;
|
||||||
|
justify-content: center !important;
|
||||||
|
text-align: center;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-queue-buttons .v-btn :deep(.v-btn__content) {
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 100%;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-queue-buttons .v-btn :deep(.v-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn-ruang {
|
||||||
|
background-color: var(--color-primary-600) !important;
|
||||||
|
color: var(--color-neutral-100) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn-ruang:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-700) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgba(58, 97, 201, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn-penunjang {
|
||||||
|
background-color: var(--color-secondary-600) !important;
|
||||||
|
color: var(--color-neutral-100) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn-penunjang:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-secondary-700) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgba(241, 111, 41, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-queue-buttons .v-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-queue-buttons .v-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .v-btn {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .v-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .v-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
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;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.patient-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icons {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.create-buttons-container {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
class="patient-card"
|
||||||
|
elevation="2"
|
||||||
|
:class="{
|
||||||
|
'clickable-card': isClickable,
|
||||||
|
'fast-track-card': isFastTrack && !isCurrentlyProcessing,
|
||||||
|
'processing-card': isCurrentlyProcessing || patient.status === 'diproses'
|
||||||
|
}"
|
||||||
|
@click="handleCardClick"
|
||||||
|
>
|
||||||
|
<v-tooltip
|
||||||
|
activator="parent"
|
||||||
|
:text="isClickable ? 'Klik untuk proses pasien' : ''"
|
||||||
|
location="top"
|
||||||
|
:disabled="!isClickable"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<div
|
||||||
|
v-if="isFastTrack"
|
||||||
|
class="fast-track-icon-wrapper"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
color="#E53935"
|
||||||
|
size="14"
|
||||||
|
class="fast-track-icon"
|
||||||
|
>
|
||||||
|
mdi-flash
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right d-flex align-center" style="gap: 8px;">
|
||||||
|
<v-chip
|
||||||
|
:color="getStatusColor(patient.status)"
|
||||||
|
size="small"
|
||||||
|
class="status-chip"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(patient.status) }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Info - Compact Single Row -->
|
||||||
|
<div class="patient-info mt-3">
|
||||||
|
<div class="info-chips-row">
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
class="klinik-chip"
|
||||||
|
>
|
||||||
|
{{ patient.klinik }}
|
||||||
|
</v-chip>
|
||||||
|
<!-- Sub Spesialis -->
|
||||||
|
<v-chip
|
||||||
|
v-if="patient.ruang"
|
||||||
|
color="primary-600"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
class="subspesialis-chip font-weight-bold"
|
||||||
|
style="height: 24px; font-size: 11px;"
|
||||||
|
>
|
||||||
|
{{ patient.ruang }}
|
||||||
|
</v-chip>
|
||||||
|
<!-- Tipe Layanan atau Jenis Pasien -->
|
||||||
|
<v-chip
|
||||||
|
v-if="displayJenisPasien"
|
||||||
|
size="small"
|
||||||
|
:color="getJenisPasienColor(displayJenisPasien)"
|
||||||
|
:variant="getJenisPasienVariant(displayJenisPasien)"
|
||||||
|
:class="getJenisPasienClass(displayJenisPasien)"
|
||||||
|
>
|
||||||
|
{{ displayJenisPasien }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<!-- Nama Dokter untuk Pasien Eksekutif -->
|
||||||
|
<div v-if="isEksekutif && patient.namaDokter" class="doctor-info mt-2">
|
||||||
|
<v-icon size="16" color="primary" class="doctor-icon">mdi-doctor</v-icon>
|
||||||
|
<span class="doctor-name">{{ patient.namaDokter }}</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>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useQueueStore } from '~/stores/queueStore';
|
||||||
|
|
||||||
|
const queueStore = useQueueStore();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
patient: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['action']);
|
||||||
|
|
||||||
|
const isClickable = computed(() => {
|
||||||
|
return props.patient.status === 'di-loket' || props.patient.status === 'pending';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFastTrack = computed(() => {
|
||||||
|
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
|
||||||
|
return fastTrack === 'YA';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCurrentlyProcessing = computed(() => {
|
||||||
|
const currentPatient = queueStore.currentProcessingPatient?.loket;
|
||||||
|
if (!currentPatient || !props.patient) return false;
|
||||||
|
return currentPatient.no === props.patient.no ||
|
||||||
|
currentPatient.barcode === props.patient.barcode;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEksekutif = computed(() => {
|
||||||
|
const pembayaran = (props.patient.pembayaran ?? "").toString().trim().toUpperCase();
|
||||||
|
return pembayaran === 'EKSEKUTIF';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed untuk menentukan jenis pasien yang ditampilkan
|
||||||
|
const displayJenisPasien = computed(() => {
|
||||||
|
// Prioritas 1: Tipe Layanan jika ada
|
||||||
|
if (props.patient.tipeLayanan) {
|
||||||
|
return props.patient.tipeLayanan;
|
||||||
|
}
|
||||||
|
// Prioritas 2: Eksekutif jika pasien eksekutif
|
||||||
|
if (isEksekutif.value) {
|
||||||
|
return 'Eksekutif';
|
||||||
|
}
|
||||||
|
// Prioritas 3: Tampilkan pembayaran sebagai fallback
|
||||||
|
if (props.patient.pembayaran) {
|
||||||
|
return props.patient.pembayaran;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fungsi untuk menentukan color chip berdasarkan jenis pasien
|
||||||
|
const getJenisPasienColor = (jenis) => {
|
||||||
|
if (jenis === 'Pemeriksaan Awal') return 'primary';
|
||||||
|
if (jenis === 'Tindakan') return 'secondary';
|
||||||
|
if (jenis === 'Eksekutif') return 'warning';
|
||||||
|
if (jenis === 'Fast Track') return 'warning';
|
||||||
|
// Untuk pembayaran biasa (BPJS, UMUM), gunakan neutral-600 untuk kontras
|
||||||
|
return 'success-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fungsi untuk menentukan variant chip berdasarkan jenis pasien
|
||||||
|
const getJenisPasienVariant = (jenis) => {
|
||||||
|
// Semua jenis pasien menggunakan flat (filled) untuk kontras dengan klinik (outlined)
|
||||||
|
return 'flat';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fungsi untuk menentukan class chip berdasarkan jenis pasien
|
||||||
|
const getJenisPasienClass = (jenis) => {
|
||||||
|
if (jenis === 'Pemeriksaan Awal' || jenis === 'Tindakan' || jenis === 'Eksekutif' || jenis === 'Fast Track') {
|
||||||
|
return 'text-white layanan-chip';
|
||||||
|
}
|
||||||
|
// Untuk pembayaran biasa (BPJS, UMUM), gunakan text putih untuk kontras dengan background
|
||||||
|
return 'text-white layanan-chip';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
if (isClickable.value) {
|
||||||
|
emit('action', props.patient, 'proses');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
const colors = {
|
||||||
|
'di-loket': "var(--color-primary-600)", // Blue
|
||||||
|
diproses: "var(--color-primary-600)", // Keep as Blue
|
||||||
|
terlambat: "var(--color-danger-700)", // Darker Red
|
||||||
|
pending: "var(--color-secondary-600)" // Orange
|
||||||
|
};
|
||||||
|
return colors[status] || "var(--color-neutral-600)";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
'di-loket': "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fast-track-card {
|
||||||
|
background: #FBE9E8 !important;
|
||||||
|
border-color: rgba(255, 132, 65, 0.5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #F1B3AE !important;
|
||||||
|
border-color: rgba(255, 132, 65, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.processing-card {
|
||||||
|
background: var(--color-primary-100) !important;
|
||||||
|
border-color: var(--color-primary-400);
|
||||||
|
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2) !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary-200) !important;
|
||||||
|
border-color: var(--color-primary-500);
|
||||||
|
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.25) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-wrapper {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(239, 83, 80, 0.15);
|
||||||
|
border: 2px solid rgba(229, 57, 53, 0.4);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.fast-track-icon-wrapper .fast-track-icon {
|
||||||
|
color: #E53935 !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
/* Tinggi dinamis berdasarkan konten - card tanpa dokter akan lebih compact */
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chips-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 24px;
|
||||||
|
border-color: var(--color-neutral-600) !important;
|
||||||
|
color: var(--color-neutral-900) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layanan-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,227 @@
|
|||||||
|
<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">
|
||||||
|
<span class="quota-label">Menunggu</span>
|
||||||
|
<span class="quota-value quota-waiting">{{ menungguCount }}</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">Selesai: {{ usedQuota }}</span>
|
||||||
|
<span class="quota-callable">Bisa dipanggil: {{ callableQuota }}</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
|
||||||
|
},
|
||||||
|
menungguCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
hasNext: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableQuota = computed(() => props.totalQuota - props.usedQuota);
|
||||||
|
const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100);
|
||||||
|
// Kuota yang bisa dipanggil = Tersedia - Menunggu
|
||||||
|
const callableQuota = computed(() => {
|
||||||
|
const callable = availableQuota.value - props.menungguCount;
|
||||||
|
return Math.max(0, callable);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-actions-card :deep(.v-card-text) {
|
||||||
|
padding: 16px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-info {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-bottom: 12px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
grid-auto-flow: row !important;
|
||||||
|
grid-auto-rows: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-item {
|
||||||
|
background: var(--color-neutral-300) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 10px 6px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-item.full-width {
|
||||||
|
grid-column: 1 / 4 !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-available {
|
||||||
|
color: var(--color-success-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-waiting {
|
||||||
|
color: var(--color-warning-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-callable {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 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;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
height: 44px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-buttons .v-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-buttons .v-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.call-buttons {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-info {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-item {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div v-if="filteredAndSearchedItems.length > 0" key="patient-list-wrapper" class="patient-list-wrapper">
|
||||||
|
<!-- Results Info -->
|
||||||
|
<div class="results-info mb-3">
|
||||||
|
<span class="results-text">
|
||||||
|
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Cards Grid -->
|
||||||
|
<div class="patient-grid">
|
||||||
|
<PatientCard
|
||||||
|
v-for="(patient, index) in paginatedItems"
|
||||||
|
:key="`${patient.barcode}-${index}`"
|
||||||
|
:patient="patient"
|
||||||
|
@action="handleAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="empty-state" key="patient-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
|
||||||
|
},
|
||||||
|
anjunganCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
showDiproses: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
itemsPerPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 12
|
||||||
|
},
|
||||||
|
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: 'di-loket', 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambahkan kategori "Anjungan" (pasien yang sudah dipanggil tapi belum check-in)
|
||||||
|
if (props.anjunganCount > 0) {
|
||||||
|
baseOptions.push({
|
||||||
|
value: 'anjungan',
|
||||||
|
label: 'Anjungan',
|
||||||
|
count: props.anjunganCount,
|
||||||
|
icon: 'mdi-clock-outline'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-neutral-400);
|
||||||
|
border-left: 3px solid var(--color-primary-600);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 961px) and (max-width: 1264px) {
|
||||||
|
.patient-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1265px) {
|
||||||
|
.patient-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="internalModel" max-width="500px">
|
||||||
|
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
|
||||||
|
<!-- Solid Blue Header -->
|
||||||
|
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
|
||||||
|
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
|
||||||
|
</div>
|
||||||
|
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="dialog-content-premium pa-6 text-center">
|
||||||
|
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
|
||||||
|
Tindakan Diperlukan
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-1 mb-6">
|
||||||
|
Pasien ini <strong>sudah dibuatkan</strong> tiket antrean ruang atau penunjang, namun statusnya belum diselesaikan di Loket.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
|
||||||
|
<div class="comparison-item current">
|
||||||
|
<div class="item-label text-center">PASIEN AKTIF</div>
|
||||||
|
<div class="item-card">
|
||||||
|
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
|
||||||
|
<div class="item-status">Menunggu Selesai</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-2 text-neutral-600">
|
||||||
|
Apakah Anda ingin menyelesaikan pasien ini sekarang dan melanjutkan ke antrean berikutnya?
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-4 bg-light justify-center">
|
||||||
|
<v-btn
|
||||||
|
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
|
||||||
|
variant="outlined"
|
||||||
|
@click="internalModel = false"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="px-6 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
|
||||||
|
variant="flat"
|
||||||
|
@click="confirm"
|
||||||
|
size="large"
|
||||||
|
elevation="2"
|
||||||
|
>
|
||||||
|
Ya, Selesaikan
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
patient: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||||
|
|
||||||
|
const internalModel = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
emit('confirm');
|
||||||
|
internalModel.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-card {
|
||||||
|
/* No border to prevent white outline */
|
||||||
|
}
|
||||||
|
.bg-light {
|
||||||
|
background-color: var(--color-neutral-50) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.comparison-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-neutral-400);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger-700 {
|
||||||
|
color: var(--color-danger-700) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-batal {
|
||||||
|
border-color: var(--color-neutral-300) !important;
|
||||||
|
color: var(--color-neutral-700) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background-color: var(--color-primary-600) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu
|
||||||
|
v-model="menu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
location="top end"
|
||||||
|
offset="8"
|
||||||
|
origin="bottom right"
|
||||||
|
transition="slide-y-transition"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
|
<div
|
||||||
|
v-bind="menuProps"
|
||||||
|
class="d-flex align-center cursor-pointer pa-2 rounded-lg hover-bg"
|
||||||
|
>
|
||||||
|
<v-avatar size="40">
|
||||||
|
<v-img
|
||||||
|
:src="user.picture"
|
||||||
|
:alt="`${user.name} Profile`"
|
||||||
|
></v-img>
|
||||||
|
<v-badge
|
||||||
|
dot
|
||||||
|
color="orange"
|
||||||
|
location="bottom right"
|
||||||
|
offset-x="2"
|
||||||
|
offset-y="2"
|
||||||
|
></v-badge>
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div v-show="!rail" class="ml-3 flex-grow-1">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">
|
||||||
|
{{ user.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn v-show="!rail" icon size="small" variant="text">
|
||||||
|
<v-icon size="20">mdi-dots-vertical</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-card width="300" elevation="8" rounded="lg">
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<v-avatar size="48">
|
||||||
|
<v-img :src="user.picture"></v-img>
|
||||||
|
</v-avatar>
|
||||||
|
<div class="ml-3">
|
||||||
|
<div class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ user.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-list density="compact" class="pa-0">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-account-circle"
|
||||||
|
class="px-2 rounded-lg"
|
||||||
|
link
|
||||||
|
@click="handleAction('profile')"
|
||||||
|
>
|
||||||
|
<v-list-item-title class="text-body-2">Profil</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-cog-outline"
|
||||||
|
class="px-2 rounded-lg"
|
||||||
|
link
|
||||||
|
@click="handleAction('setting')"
|
||||||
|
>
|
||||||
|
<v-list-item-title class="text-body-2">Pengaturan</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider class="my-2"></v-divider>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-logout"
|
||||||
|
class="px-2 rounded-lg text-red"
|
||||||
|
link
|
||||||
|
@click="signOut"
|
||||||
|
:disabled="isLoggingOut"
|
||||||
|
>
|
||||||
|
<v-list-item-title class="text-body-2">
|
||||||
|
{{ isLoggingOut ? 'Log out...' : 'Log out' }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey mt-3 text-center">
|
||||||
|
v2.5.18 · Terms & Conditions
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { navigateTo } from '#app';
|
||||||
|
|
||||||
|
// --- PROPS & EMITS ---
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
// Diubah menjadi required: false dengan default yang aman
|
||||||
|
required: false,
|
||||||
|
default: () => ({
|
||||||
|
name: 'Guest',
|
||||||
|
email: '[email protected]',
|
||||||
|
picture: 'https://i.pravatar.cc/150?img=33'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['logout']); // Hanya emit logout
|
||||||
|
|
||||||
|
// --- STATE ---
|
||||||
|
const menu = ref(false);
|
||||||
|
const isLoggingOut = ref(false);
|
||||||
|
|
||||||
|
// --- METHODS ---
|
||||||
|
const signOut = () => {
|
||||||
|
if (isLoggingOut.value) return;
|
||||||
|
isLoggingOut.value = true;
|
||||||
|
menu.value = false;
|
||||||
|
|
||||||
|
// Memicu event untuk ditangani oleh komponen induk (SideBar)
|
||||||
|
emit('logout');
|
||||||
|
// Biarkan parent yang mengurus redirect/state global
|
||||||
|
setTimeout(() => isLoggingOut.value = false, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = (action) => {
|
||||||
|
console.log(`[PopupSidebar] Action: ${action} triggered.`);
|
||||||
|
switch(action) {
|
||||||
|
case 'profile':
|
||||||
|
navigateTo('/Profile/Profil');
|
||||||
|
break;
|
||||||
|
case 'setting':
|
||||||
|
navigateTo('/Profile/Pengaturan');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
menu.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Tambahkan kembali style yang relevan untuk aktivator di sini */
|
||||||
|
.hover-bg:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.text-red {
|
||||||
|
color: rgb(244, 67, 54) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<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="profile-avatar-container">
|
||||||
|
<v-avatar size="44" class="profile-avatar elevation-8">
|
||||||
|
<v-img
|
||||||
|
:src="getAvatarSrc(user?.picture)"
|
||||||
|
: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="profile-avatar-container">
|
||||||
|
<v-avatar size="44" class="profile-avatar elevation-8">
|
||||||
|
<v-img
|
||||||
|
:src="getAvatarSrc(user?.picture)"
|
||||||
|
: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="profile-avatar-container mb-4">
|
||||||
|
<v-avatar size="90" class="profile-avatar elevation-8">
|
||||||
|
<v-img
|
||||||
|
:src="getAvatarSrc(user?.picture)"
|
||||||
|
: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="primary-600">mdi-account-circle</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption font-weight-medium">Profil</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';
|
||||||
|
import { DEFAULT_AVATAR } from '@/constants/avatar';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const menu = ref(false);
|
||||||
|
const isLoggingOut = ref(false);
|
||||||
|
const emit = defineEmits(['logout']);
|
||||||
|
|
||||||
|
const getAvatarSrc = (userPicture) => {
|
||||||
|
return userPicture || DEFAULT_AVATAR;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 'profile':
|
||||||
|
navigateTo('/Profile/Profil')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '@/assets/scss/_colors.scss';
|
||||||
|
|
||||||
|
.profile-activator {
|
||||||
|
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
|
||||||
|
border: 1px solid $primary-200;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-activator:hover {
|
||||||
|
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
|
||||||
|
border-color: $primary-300;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
border: 3px solid white;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(135deg, $success-600 0%, $success-400 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 $primary-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, $primary-700 0%, $primary-500 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: linear-gradient(135deg, $success-600 0%, $success-400 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: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile {
|
||||||
|
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
|
||||||
|
border: 1px solid $primary-200;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile:hover {
|
||||||
|
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
|
||||||
|
border-color: $primary-300;
|
||||||
|
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, $primary-100 0%, $primary-50 100%);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: 0 4px 12px rgba(222, 71, 59, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(222, 71, 59, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-90 {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
<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-3 sidebar-logo-container" :class="{ 'text-center': rail }">
|
||||||
|
<NuxtLink to="/dashboard" class="sidebar-logo-link">
|
||||||
|
<div class="d-flex align-center sidebar-logo-wrapper" :class="{ 'justify-center': rail }">
|
||||||
|
<div class="sidebar-logo-image-wrapper">
|
||||||
|
<img
|
||||||
|
src="/love logo biru small.png"
|
||||||
|
alt="Antrean Logo"
|
||||||
|
class="sidebar-logo"
|
||||||
|
:class="{ 'sidebar-logo-rail': rail }"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
style="width: 32px; height: 32px; object-fit: contain;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-list-item-title
|
||||||
|
v-if="!rail"
|
||||||
|
class="ml-2 text-h6 font-weight-bold sidebar-title"
|
||||||
|
>
|
||||||
|
<span class="sidebar-title-accent">Antrean</span> RSSA
|
||||||
|
</v-list-item-title>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</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">
|
||||||
|
<!-- Popup Menu ketika locked dan rail -->
|
||||||
|
<v-menu
|
||||||
|
v-if="item.children && rail && isLocked"
|
||||||
|
location="end"
|
||||||
|
offset="8"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-tooltip
|
||||||
|
open-on-hover
|
||||||
|
location="end"
|
||||||
|
:text="item.name"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="{ ...menuProps, ...tooltipProps }"
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.name"
|
||||||
|
class="sidebar-nav-item"
|
||||||
|
active-class="sidebar-nav-item-active"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact" class="sidebar-popup-menu">
|
||||||
|
<v-tooltip
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.name"
|
||||||
|
open-on-hover
|
||||||
|
location="end"
|
||||||
|
:text="child.name"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
:to="child.path"
|
||||||
|
:title="child.name"
|
||||||
|
:prepend-icon="child.icon"
|
||||||
|
link
|
||||||
|
class="sidebar-nav-item"
|
||||||
|
active-class="sidebar-nav-item-active"
|
||||||
|
@click="closePopup"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- Dropdown normal ketika tidak locked atau expanded -->
|
||||||
|
<v-list-group
|
||||||
|
v-else-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"
|
||||||
|
class="sidebar-nav-item"
|
||||||
|
active-class="sidebar-nav-item-active"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-tooltip
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.name"
|
||||||
|
open-on-hover
|
||||||
|
location="end"
|
||||||
|
:text="child.name"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
:to="child.path"
|
||||||
|
:title="child.name"
|
||||||
|
:prepend-icon="child.icon"
|
||||||
|
link
|
||||||
|
class="pl-8 sidebar-nav-item"
|
||||||
|
active-class="sidebar-nav-item-active"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-list-group>
|
||||||
|
|
||||||
|
<v-tooltip
|
||||||
|
v-else
|
||||||
|
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
|
||||||
|
class="sidebar-nav-item"
|
||||||
|
active-class="sidebar-nav-item-active"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-divider></v-divider>
|
||||||
|
|
||||||
|
<!-- Bottom section wrapper for profile and lock button -->
|
||||||
|
<div class="sidebar-bottom-section">
|
||||||
|
<v-list v-if="user" nav density="compact" class="pa-2">
|
||||||
|
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
|
||||||
|
</v-list>
|
||||||
|
<v-list v-else nav density="compact">
|
||||||
|
<v-list-item
|
||||||
|
@click="redirectToLogin"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
title="Login"
|
||||||
|
link
|
||||||
|
class="sidebar-login-item"
|
||||||
|
></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<!-- Custom Lock Sidebar Toggle (Lock-to-Rail) -->
|
||||||
|
<div class="sidebar-lock-wrapper" :class="{ 'sidebar-lock-rail': rail }">
|
||||||
|
<v-tooltip
|
||||||
|
open-on-hover
|
||||||
|
location="end"
|
||||||
|
:text="isLocked ? 'Aktifkan Hover' : 'Matikan Hover'"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<div
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
class="custom-dock-lock"
|
||||||
|
:class="{ 'is-locked': isLocked }"
|
||||||
|
@click="handleLockToggle(!isLocked)"
|
||||||
|
>
|
||||||
|
<div class="dock-lock-icon-box">
|
||||||
|
<v-icon size="20">
|
||||||
|
{{ isLocked ? 'mdi-dock-right' : 'mdi-dock-left' }}
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
<div v-if="!rail" class="dock-lock-details">
|
||||||
|
<span class="dock-lock-label">Sidebar Auto-Expand</span>
|
||||||
|
<span class="dock-lock-status">{{ isLocked ? 'Nonaktif' : 'Aktif' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { navigateTo } from "#app";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
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 isLocked = useLocalStorage("sidebar-locked", false);
|
||||||
|
|
||||||
|
// Reset hover state when locked
|
||||||
|
watch(isLocked, (val) => {
|
||||||
|
if (val) isHovering.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (props.rail && !isLocked.value) {
|
||||||
|
isHovering.value = true;
|
||||||
|
emit("toggle-rail");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (isHovering.value) {
|
||||||
|
isHovering.value = false;
|
||||||
|
// Only toggle rail if not locked
|
||||||
|
if (!isLocked.value) {
|
||||||
|
emit("toggle-rail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLockToggle = (value?: boolean | null) => {
|
||||||
|
const lockValue = value ?? !isLocked.value;
|
||||||
|
isLocked.value = lockValue;
|
||||||
|
|
||||||
|
// Cleanup hovering state
|
||||||
|
isHovering.value = false;
|
||||||
|
|
||||||
|
// Jika di-lock, pastikan sidebar kembali ke rail mode
|
||||||
|
if (lockValue && !props.rail) {
|
||||||
|
emit("toggle-rail");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePopup = () => {
|
||||||
|
// Popup akan otomatis tertutup saat item diklik
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 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 lang="scss" scoped>
|
||||||
|
@use '~/assets/scss/_colors.scss' as *;
|
||||||
|
|
||||||
|
:deep(.v-navigation-drawer) {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bottom-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-container {
|
||||||
|
overflow: visible !important;
|
||||||
|
height: 80px !important;
|
||||||
|
|
||||||
|
:deep(.v-list-item__content) {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-image-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 2px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-rail {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title-accent {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
|
||||||
|
:deep(.v-list-item__prepend) {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-neutral-300);
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
|
||||||
|
:deep(.v-list-item__prepend) {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item-active {
|
||||||
|
background-color: var(--color-primary-50) !important;
|
||||||
|
color: var(--color-primary-600) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
:deep(.v-list-item__prepend) {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-primary-600) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-login-item {
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
|
||||||
|
:deep(.v-list-item__prepend) {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-neutral-300);
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
|
||||||
|
:deep(.v-list-item__prepend) {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-lock-wrapper {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-top: 1px solid var(--color-neutral-300);
|
||||||
|
|
||||||
|
&.sidebar-lock-rail {
|
||||||
|
padding: 12px 6px;
|
||||||
|
justify-content: center;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-dock-lock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-locked {
|
||||||
|
background-color: var(--color-danger-100);
|
||||||
|
border-color: var(--color-danger-200);
|
||||||
|
|
||||||
|
.dock-lock-icon-box {
|
||||||
|
color: var(--color-danger-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-lock-status {
|
||||||
|
color: var(--color-danger-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-danger-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-lock-icon-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-lock-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-lock-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-lock-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-lock-rail {
|
||||||
|
.custom-dock-lock {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-lock-details {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-popup-menu {
|
||||||
|
min-width: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
|
||||||
|
:deep(.v-list-item) {
|
||||||
|
min-height: 40px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
|
||||||
|
&.sidebar-nav-item {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
|
||||||
|
.v-list-item__prepend {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-neutral-300);
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
|
||||||
|
.v-list-item__prepend {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-nav-item-active {
|
||||||
|
background-color: var(--color-primary-50) !important;
|
||||||
|
color: var(--color-primary-600) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
.v-list-item__prepend {
|
||||||
|
.v-icon {
|
||||||
|
color: var(--color-primary-600) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
<template>
|
||||||
|
<div class="loket-preview-container">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="loading-overlay">
|
||||||
|
<v-progress-circular indeterminate color="primary" size="60"></v-progress-circular>
|
||||||
|
<p class="mt-4">Memuat data loket...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div v-else class="preview-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo-circle">
|
||||||
|
<v-icon size="40" color="primary">mdi-hospital-box</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1 class="hospital-name">ANTRIAN LOKET</h1>
|
||||||
|
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||||
|
<p v-if="loketData" class="loket-info">{{ loketData.namaLoket }}</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>
|
||||||
|
|
||||||
|
<!-- Main Grid - Display Klinik -->
|
||||||
|
<div v-if="!isLoading" class="kliniks-grid">
|
||||||
|
<div
|
||||||
|
v-for="klinik in displayedClinics"
|
||||||
|
:key="klinik.name"
|
||||||
|
class="klinik-box"
|
||||||
|
>
|
||||||
|
<!-- Klinik Header -->
|
||||||
|
<div class="klinik-header-bar">
|
||||||
|
<span class="klinik-title">{{ klinik.name }}</span>
|
||||||
|
<v-chip size="small" class="klinik-count">
|
||||||
|
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
|
||||||
|
<span class="count-text">{{ klinik.totalQueues }}</span>
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue Content -->
|
||||||
|
<div class="klinik-content">
|
||||||
|
<!-- Current Serving Queue (Large) -->
|
||||||
|
<div class="current-serving-section">
|
||||||
|
<div class="current-label">SEDANG DILAYANI</div>
|
||||||
|
<div
|
||||||
|
v-if="klinik.currentQueue"
|
||||||
|
class="current-number-large"
|
||||||
|
>
|
||||||
|
{{ klinik.currentQueue.noAntrian.split(' |')[0] }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="current-waiting-text">
|
||||||
|
MENUNGGU PANGGILAN
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All Queues Grid (Small) -->
|
||||||
|
<div
|
||||||
|
v-if="klinik.allQueues && klinik.allQueues.length > 0"
|
||||||
|
class="all-queues-grid"
|
||||||
|
:style="getGridStyle(klinik.allQueues.length)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="queue in klinik.allQueues"
|
||||||
|
:key="`queue-${queue.no}`"
|
||||||
|
class="queue-grid-item"
|
||||||
|
:class="{
|
||||||
|
'is-current': queue.no === klinik.currentQueue?.no,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ queue.noAntrian.split(' |')[0] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="!klinik.currentQueue && (!klinik.allQueues || klinik.allQueues.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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useLoketStore } from '@/stores/loketStore'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loketId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loketStore = useLoketStore()
|
||||||
|
|
||||||
|
const currentTime = ref('')
|
||||||
|
const currentDate = ref('')
|
||||||
|
const isLoading = ref(true)
|
||||||
|
let timeInterval = null
|
||||||
|
|
||||||
|
// Get loket data
|
||||||
|
const loketData = computed(() => {
|
||||||
|
return loketStore.getLoketById(props.loketId) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock queue data untuk preview
|
||||||
|
const mockQueues = ref([
|
||||||
|
{ no: 1, noAntrian: 'RT001', klinik: 'R. TINDAKAN', status: 'current' },
|
||||||
|
{ no: 2, noAntrian: 'RT002', klinik: 'R. TINDAKAN', status: 'anjungan' },
|
||||||
|
{ no: 3, noAntrian: 'RD001', klinik: 'RADIOTERAPI', status: 'anjungan' },
|
||||||
|
{ no: 4, noAntrian: 'RM001', klinik: 'REHAB MEDIK', status: 'anjungan' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Simulasi klinik dengan queue
|
||||||
|
const displayedClinics = computed(() => {
|
||||||
|
// Gunakan pelayanan dari loketData jika available, atau gunakan contoh default
|
||||||
|
const pelayanan = loketData.value?.pelayanan || ['RT', 'RD', 'RM']
|
||||||
|
|
||||||
|
// Map pelayanan ke nama klinik untuk preview
|
||||||
|
const klinikNames = {
|
||||||
|
'RT': 'R. TINDAKAN',
|
||||||
|
'RD': 'RADIOTERAPI',
|
||||||
|
'RM': 'REHAB MEDIK',
|
||||||
|
'RA': 'RADIOTERAPI',
|
||||||
|
'IP': 'RAWAT INAP',
|
||||||
|
'RJ': 'RAWAT JALAN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const clinicsMap = new Map()
|
||||||
|
|
||||||
|
// Create mock clinic entries berdasarkan pelayanan
|
||||||
|
pelayanan.forEach(kode => {
|
||||||
|
const klinikName = klinikNames[kode] || kode
|
||||||
|
if (!clinicsMap.has(klinikName)) {
|
||||||
|
clinicsMap.set(klinikName, {
|
||||||
|
name: klinikName,
|
||||||
|
code: kode,
|
||||||
|
allQueues: [],
|
||||||
|
currentQueue: null,
|
||||||
|
totalQueues: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Distribute mock queues
|
||||||
|
mockQueues.value.forEach(queue => {
|
||||||
|
const klinikEntry = clinicsMap.get(queue.klinik)
|
||||||
|
if (klinikEntry) {
|
||||||
|
klinikEntry.allQueues.push(queue)
|
||||||
|
if (queue.status === 'current') {
|
||||||
|
klinikEntry.currentQueue = queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
clinicsMap.forEach(clinic => {
|
||||||
|
clinic.totalQueues = clinic.allQueues.length
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(clinicsMap.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
const getGridStyle = (queueCount) => {
|
||||||
|
if (queueCount <= 3) return { gridTemplateColumns: `repeat(${queueCount}, 1fr)` }
|
||||||
|
if (queueCount <= 6) return { gridTemplateColumns: 'repeat(3, 1fr)' }
|
||||||
|
return { gridTemplateColumns: 'repeat(4, 1fr)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
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(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch loket data dari API terlebih dahulu
|
||||||
|
console.log('📊 [LoketPreview] Fetching loket data for ID:', props.loketId);
|
||||||
|
const result = await loketStore.fetchLoketFromAPI(true)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('✅ [LoketPreview] Loket data fetched successfully:', result.message);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ [LoketPreview] Failed to fetch loket data:', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [LoketPreview] Error fetching loket data:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
// Start timer setelah data di-fetch
|
||||||
|
updateTime()
|
||||||
|
timeInterval = setInterval(updateTime, 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// Color variables
|
||||||
|
$primary-600: #3A61C9;
|
||||||
|
$primary-700: #3556AE;
|
||||||
|
$primary-50: #F0F4FF;
|
||||||
|
$primary-200: #9AB2F1;
|
||||||
|
$neutral-100: #FFFFFF;
|
||||||
|
$neutral-300: #F5F7FA;
|
||||||
|
$neutral-600: #89939E;
|
||||||
|
$neutral-700: #717171;
|
||||||
|
$neutral-900: #212121;
|
||||||
|
$warning-600: #FF9800;
|
||||||
|
$warning-700: #F57C00;
|
||||||
|
$warning-100: #FFF3E0;
|
||||||
|
|
||||||
|
.loket-preview-container {
|
||||||
|
background: $neutral-300;
|
||||||
|
padding: 24px;
|
||||||
|
font-family: 'Inter', 'Roboto', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== LOADING STATE ========== */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay p {
|
||||||
|
color: $neutral-700;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== HEADER ========== */
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 40px;
|
||||||
|
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hospital-name {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 800;
|
||||||
|
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: $neutral-100;
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loket-info {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $neutral-100;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-display {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-large {
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 900;
|
||||||
|
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: $neutral-100;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== KLINIKS GRID ========== */
|
||||||
|
.kliniks-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-box {
|
||||||
|
background: $neutral-100;
|
||||||
|
border: 2px solid $primary-200;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-header-bar {
|
||||||
|
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||||
|
padding: 18px 22px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $neutral-100;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.2;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-count {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
color: $neutral-100 !important;
|
||||||
|
font-weight: 700;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 22px;
|
||||||
|
background: $primary-50;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current Serving Section */
|
||||||
|
.current-serving-section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-number-large {
|
||||||
|
font-size: 80px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $neutral-900;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-waiting-text {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $neutral-600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 40px 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All Queues Grid */
|
||||||
|
.all-queues-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-grid-item {
|
||||||
|
background: $neutral-100;
|
||||||
|
border: 2px solid $neutral-600;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $neutral-700;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&.is-current {
|
||||||
|
background: $warning-100;
|
||||||
|
border: 3px solid $warning-600;
|
||||||
|
color: $warning-700;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 2px 6px rgba(255, 152, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $neutral-600;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1366px) {
|
||||||
|
.hospital-name {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-large {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klinik-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-number-large {
|
||||||
|
font-size: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-waiting-text {
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.loket-preview-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kliniks-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hospital-name {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-large {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-number-large {
|
||||||
|
font-size: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<template>
|
||||||
|
<v-card-text class="pa-0 bg-white">
|
||||||
|
<v-table class="patient-table">
|
||||||
|
<!-- Table Body -->
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="patient in patients"
|
||||||
|
:key="patient.rm"
|
||||||
|
class="table-row"
|
||||||
|
:class="{ 'verified-row': patient.status === 'Terverifikasi' }"
|
||||||
|
>
|
||||||
|
<!-- Status Avatar -->
|
||||||
|
<td class="table-cell status-cell">
|
||||||
|
<div class="status-wrapper">
|
||||||
|
<v-avatar
|
||||||
|
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
|
||||||
|
size="48"
|
||||||
|
class="patient-avatar"
|
||||||
|
>
|
||||||
|
<v-icon size="24" color="white">
|
||||||
|
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Nama -->
|
||||||
|
<td class="table-cell">
|
||||||
|
<span class="patient-name">{{ patient.nama }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- RM -->
|
||||||
|
<td class="table-cell">
|
||||||
|
<v-chip size="small" color="primary-200" class="chip-rm" variant="flat">
|
||||||
|
<v-icon start size="14" color="secondary-600">mdi-file-document</v-icon>
|
||||||
|
<span class="chip-rm-text">{{ patient.rm }}</span>
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Alamat -->
|
||||||
|
<td class="table-cell">
|
||||||
|
<div class="info-item">
|
||||||
|
<v-icon size="14" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
|
||||||
|
<span>{{ patient.alamat }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Nomor Telepon -->
|
||||||
|
<td class="table-cell">
|
||||||
|
<div class="info-item">
|
||||||
|
<v-icon size="14" class="mr-2" color="neutral-600">mdi-phone</v-icon>
|
||||||
|
<span>{{ patient.telepon || 'Belum diisi' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Aksi -->
|
||||||
|
<td class="table-cell action-col">
|
||||||
|
<div class="action-wrapper">
|
||||||
|
<v-btn
|
||||||
|
v-if="patient.status === 'Belum Terverifikasi'"
|
||||||
|
variant="flat"
|
||||||
|
class="action-btn"
|
||||||
|
@click="$emit('verify', patient)"
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-qrcode-scan</v-icon>
|
||||||
|
VERIFIKASI
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
color="secondary-600"
|
||||||
|
variant="flat"
|
||||||
|
class="verified-btn"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-shield-check</v-icon>
|
||||||
|
VERIFIED
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<tr v-if="patients.length === 0">
|
||||||
|
<td colspan="6" class="empty-state-cell">
|
||||||
|
<div class="empty-state">{{ emptyMessage }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</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-500: #ABBED1;
|
||||||
|
$neutral-600: #89939E;
|
||||||
|
$neutral-700: #717171;
|
||||||
|
$neutral-900: #212121;
|
||||||
|
$primary-100: #FFE8CC;
|
||||||
|
$primary-200: #FFDCAF;
|
||||||
|
$primary-600: #FFA532;
|
||||||
|
$primary-700: #FF9B1B;
|
||||||
|
$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-table {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
|
||||||
|
:deep(thead),
|
||||||
|
:deep(tbody),
|
||||||
|
:deep(tr) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TABLE HEADER
|
||||||
|
// ============================================
|
||||||
|
.table-header {
|
||||||
|
background-color: $neutral-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-cell {
|
||||||
|
padding: 16px 24px !important;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $neutral-900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
border-bottom: 2px solid $neutral-500;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&.status-header-hidden {
|
||||||
|
padding: 16px 24px !important;
|
||||||
|
width: 96px;
|
||||||
|
min-width: 96px;
|
||||||
|
max-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-col {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TABLE BODY
|
||||||
|
// ============================================
|
||||||
|
.table-row {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $primary-100 !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 4px;
|
||||||
|
background: $primary-600;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-row {
|
||||||
|
background-color: $secondary-200 !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: $secondary-600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $secondary-300 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
padding: 16px 24px !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
border-bottom: 1px solid $neutral-400;
|
||||||
|
|
||||||
|
&.status-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 24px !important;
|
||||||
|
width: 96px;
|
||||||
|
min-width: 96px;
|
||||||
|
max-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-col {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-avatar {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 3px solid $neutral-100;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: $font-weight-extra-bold;
|
||||||
|
color: $neutral-900;
|
||||||
|
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-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background-color: $primary-700 !important;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: $font-weight-extra-bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $neutral-100 !important;
|
||||||
|
padding: 8px 24px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
min-width: 150px;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.4), 0 2px 6px rgba(255, 155, 27, 0.3);
|
||||||
|
font-family: $font-family-base;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background-color: #E68A00 !important;
|
||||||
|
box-shadow: 0 6px 16px rgba(255, 155, 27, 0.5), 0 3px 8px rgba(255, 155, 27, 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-btn {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: $font-weight-extra-bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $neutral-100 !important;
|
||||||
|
padding: 8px 24px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
min-width: 150px;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-cell {
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
font-size: 18px;
|
||||||
|
color: $neutral-600;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// RESPONSIVE
|
||||||
|
// ============================================
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.patient-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
padding: 20px 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-cell {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn,
|
||||||
|
.verified-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-avatar {
|
||||||
|
width: 44px !important;
|
||||||
|
height: 44px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
max-width="600"
|
||||||
|
transition="dialog-bottom-transition"
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<v-card class="modal-card">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="modal-header">
|
||||||
|
<v-icon size="72" color="white" class="mb-3">mdi-qrcode-scan</v-icon>
|
||||||
|
<h2 class="modal-title">Aktivasi Akun</h2>
|
||||||
|
<p class="modal-subtitle">{{ patient.nama }}</p>
|
||||||
|
<v-chip color="white" class="modal-chip" variant="flat" size="default">
|
||||||
|
<span class="modal-chip-text">RM: {{ patient.rm }}</span>
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<v-card-text class="pa-7">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="phoneNumber"
|
||||||
|
@update:model-value="$emit('update:phoneNumber', $event)"
|
||||||
|
:disabled="qrGenerated"
|
||||||
|
label="Nomor Telepon Pasien"
|
||||||
|
placeholder="08xxxxxxxxxx"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
:color="qrGenerated ? 'grey' : 'primary-600'"
|
||||||
|
:rules="[v => v.length >= 8 || 'Min. 8 digit']"
|
||||||
|
prepend-inner-icon="mdi-phone"
|
||||||
|
class="mb-3 phone-field"
|
||||||
|
base-color="grey-darken-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="!qrGenerated"
|
||||||
|
:disabled="!isPhoneValid"
|
||||||
|
color="secondary-600"
|
||||||
|
block
|
||||||
|
size="x-large"
|
||||||
|
class="generate-btn"
|
||||||
|
@click="$emit('generate')"
|
||||||
|
rounded="xl"
|
||||||
|
>
|
||||||
|
<v-icon left size="32">mdi-qrcode-plus</v-icon>
|
||||||
|
Generate QR Code
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- QR Code Container -->
|
||||||
|
<div v-else class="qr-container">
|
||||||
|
<div class="pulse-icon">
|
||||||
|
<v-icon color="secondary-600" :size="isMobile ? 48 : 64">
|
||||||
|
mdi-cellphone-check
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="qr-title">Pindai QR Code</h3>
|
||||||
|
<p class="qr-subtitle">Arahkan kamera smartphone ke QR code</p>
|
||||||
|
|
||||||
|
<div class="d-flex justify-center mb-3 mb-sm-4">
|
||||||
|
<div class="qr-frame">
|
||||||
|
<slot name="qr-code" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<!-- Modal Actions -->
|
||||||
|
<v-card-actions v-if="qrGenerated" class="modal-actions">
|
||||||
|
<v-btn
|
||||||
|
color="secondary-600"
|
||||||
|
variant="outlined"
|
||||||
|
@click="$emit('reload')"
|
||||||
|
prepend-icon="mdi-reload"
|
||||||
|
size="x-large"
|
||||||
|
class="modal-action-btn"
|
||||||
|
rounded="xl"
|
||||||
|
>
|
||||||
|
Reload QR
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="primary-600"
|
||||||
|
variant="flat"
|
||||||
|
@click="$emit('complete')"
|
||||||
|
prepend-icon="mdi-check-circle"
|
||||||
|
size="x-large"
|
||||||
|
class="modal-action-btn-primary"
|
||||||
|
rounded="xl"
|
||||||
|
>
|
||||||
|
Selesai
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
|
||||||
|
<v-card-actions v-else class="modal-actions">
|
||||||
|
<v-btn
|
||||||
|
color="neutral-600"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('close')"
|
||||||
|
size="x-large"
|
||||||
|
block
|
||||||
|
rounded="xl"
|
||||||
|
class="modal-cancel-btn"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
patient: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
phoneNumber: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
qrGenerated: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isMobile: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:modelValue',
|
||||||
|
'update:phoneNumber',
|
||||||
|
'generate',
|
||||||
|
'reload',
|
||||||
|
'complete',
|
||||||
|
'close'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isPhoneValid = computed(() => props.phoneNumber.length >= 8);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$neutral-100: #FFFFFF;
|
||||||
|
$neutral-600: #89939E;
|
||||||
|
$neutral-700: #717171;
|
||||||
|
$primary-600: #FFA532;
|
||||||
|
$secondary-200: #EDF5FF;
|
||||||
|
$secondary-600: #0671E0;
|
||||||
|
$secondary-700: #0053AD;
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
$font-weight-bold: 700;
|
||||||
|
$font-weight-extra-bold: 800;
|
||||||
|
$font-weight-black: 900;
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
|
||||||
|
text-align: center;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 4px 12px rgba(6, 99, 199, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: $font-weight-black;
|
||||||
|
color: $neutral-100;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-subtitle {
|
||||||
|
font-size: 20px;
|
||||||
|
color: $neutral-100;
|
||||||
|
opacity: 0.95;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-chip {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-chip-text {
|
||||||
|
color: $primary-600;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-field {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: $font-weight-extra-bold;
|
||||||
|
font-size: 18px;
|
||||||
|
color: $neutral-100;
|
||||||
|
box-shadow: 0 2px 8px rgba(6, 99, 199, 0.25);
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
background: $secondary-200;
|
||||||
|
border: 3px solid $secondary-600;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
box-shadow: 0 6px 20px rgba(6, 99, 199, 0.15);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.qr-container {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: $font-weight-black;
|
||||||
|
color: $secondary-700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.qr-title {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $neutral-700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.qr-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-frame {
|
||||||
|
padding: 16px;
|
||||||
|
background: $neutral-100;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
display: inline-block;
|
||||||
|
border: 3px solid $secondary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.qr-frame {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 4px solid $secondary-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
padding: 28px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-action-btn,
|
||||||
|
.modal-action-btn-primary {
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: none;
|
||||||
|
flex-grow: 1;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-action-btn-primary {
|
||||||
|
color: $neutral-100;
|
||||||
|
font-weight: $font-weight-extra-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-cancel-btn {
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: none;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<v-row class="search-section">
|
||||||
|
<v-col cols="12" sm="6" class="py-2">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="searchQuery"
|
||||||
|
@update:model-value="$emit('update:searchQuery', $event)"
|
||||||
|
density="comfortable"
|
||||||
|
label="Cari pasien berdasarkan nama atau RM..."
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
color="primary-600"
|
||||||
|
bg-color="white"
|
||||||
|
class="search-field"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6" class="py-2">
|
||||||
|
<v-tabs
|
||||||
|
:model-value="selectedTab"
|
||||||
|
@update:model-value="$emit('update:selectedTab', $event)"
|
||||||
|
color="primary-600"
|
||||||
|
align-tabs="end"
|
||||||
|
class="filter-tabs"
|
||||||
|
slider-color="primary-600"
|
||||||
|
>
|
||||||
|
<v-tab :value="0" class="tab-item">
|
||||||
|
SEMUA
|
||||||
|
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
|
||||||
|
{{ allCount }}
|
||||||
|
</v-chip>
|
||||||
|
</v-tab>
|
||||||
|
|
||||||
|
<v-tab :value="1" class="tab-item">
|
||||||
|
PENDING
|
||||||
|
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
|
||||||
|
{{ pendingCount }}
|
||||||
|
</v-chip>
|
||||||
|
</v-tab>
|
||||||
|
|
||||||
|
<v-tab :value="2" class="tab-item">
|
||||||
|
VERIFIED
|
||||||
|
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
|
||||||
|
{{ verifiedCount }}
|
||||||
|
</v-chip>
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
searchQuery: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
selectedTab: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
allCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
pendingCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
verifiedCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['update:searchQuery', 'update:selectedTab']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$neutral-100: #FFFFFF;
|
||||||
|
$neutral-500: #ABBED1;
|
||||||
|
$neutral-600: #89939E;
|
||||||
|
$neutral-300: #F5F7FA;
|
||||||
|
$primary-600: #FFA532;
|
||||||
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
$font-weight-bold: 700;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
background: $neutral-300;
|
||||||
|
border-bottom: 1px solid $neutral-500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
background: transparent;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-count {
|
||||||
|
color: #000 !important;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// composables/useAPI.ts
|
||||||
|
// Generic API composable untuk semua API calls
|
||||||
|
// Base composable yang bisa di-extend untuk specific resources
|
||||||
|
|
||||||
|
export interface APIResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAPI = () => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const baseURL = config.public.apiBaseUrl || '/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic GET request
|
||||||
|
*/
|
||||||
|
const get = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data !== undefined) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.message || 'Failed to fetch data');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Error GET ${endpoint}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic POST request
|
||||||
|
*/
|
||||||
|
const post = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data !== undefined) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.message || 'Failed to create data');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Error POST ${endpoint}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic PATCH request
|
||||||
|
*/
|
||||||
|
const patch = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data !== undefined) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.message || 'Failed to update data');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Error PATCH ${endpoint}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic DELETE request
|
||||||
|
*/
|
||||||
|
const del = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
return response.data as T;
|
||||||
|
}
|
||||||
|
throw new Error(response.message || 'Failed to delete data');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Error DELETE ${endpoint}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
patch,
|
||||||
|
delete: del,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// 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 headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
|
||||||
|
|
||||||
|
// The session API returns SessionResponse, or throws 401 if not authenticated
|
||||||
|
// $fetch automatically sends cookies for same-origin requests
|
||||||
|
const response = await $fetch<SessionResponse>('/api/auth/session', {
|
||||||
|
headers,
|
||||||
|
credentials: 'include' // Explicitly include cookies (though $fetch does this by default)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle response structure - session API returns { success, user, ... }
|
||||||
|
if (response && response.user) {
|
||||||
|
user.value = response.user
|
||||||
|
return response.user
|
||||||
|
}
|
||||||
|
|
||||||
|
// If response exists but no user, clear and return null
|
||||||
|
user.value = null
|
||||||
|
return null
|
||||||
|
} catch (fetchError: any) {
|
||||||
|
console.error('Session check failed:', fetchError)
|
||||||
|
// 401 errors are expected when not authenticated
|
||||||
|
if (fetchError.statusCode === 401 || fetchError.status === 401) {
|
||||||
|
error.value = null // Don't show error for expected unauthenticated state
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to check authentication status'
|
||||||
|
}
|
||||||
|
user.value = null
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
clearError()
|
||||||
|
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response?.success && response?.data?.authUrl) {
|
||||||
|
console.log('🔗 Redirecting to Keycloak login...')
|
||||||
|
window.location.href = response.data.authUrl
|
||||||
|
} else {
|
||||||
|
const errorMsg = response?.error || 'Failed to get authorization URL'
|
||||||
|
error.value = errorMsg
|
||||||
|
throw new Error(errorMsg)
|
||||||
|
}
|
||||||
|
} catch (loginError: any) {
|
||||||
|
console.error('❌ Login error:', loginError)
|
||||||
|
error.value = loginError.message || 'Login failed'
|
||||||
|
throw loginError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
console.log('🚪 Starting logout process...')
|
||||||
|
|
||||||
|
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear user immediately regardless of response
|
||||||
|
user.value = null
|
||||||
|
|
||||||
|
if (response?.success && response?.logoutUrl) {
|
||||||
|
console.log('🔗 Redirecting to Keycloak logout...')
|
||||||
|
window.location.href = response.logoutUrl
|
||||||
|
} else {
|
||||||
|
const warningMsg = response?.error || response?.message || 'No logout URL received'
|
||||||
|
console.warn('⚠️', warningMsg)
|
||||||
|
error.value = warningMsg
|
||||||
|
await navigateTo('/LoginPage')
|
||||||
|
}
|
||||||
|
} catch (logoutError: any) {
|
||||||
|
console.error('❌ Logout error:', logoutError)
|
||||||
|
error.value = logoutError.message || 'Logout failed'
|
||||||
|
user.value = null
|
||||||
|
await navigateTo('/LoginPage')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to refresh user data
|
||||||
|
const refreshUser = async (): Promise<boolean> => {
|
||||||
|
const userData = await checkAuth()
|
||||||
|
return !!userData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if user has specific role
|
||||||
|
const hasRole = (role: string): boolean => {
|
||||||
|
if (!user.value) return false
|
||||||
|
|
||||||
|
// Check in roles array
|
||||||
|
if (user.value.roles?.includes(role)) return true
|
||||||
|
|
||||||
|
// Check in realm_access.roles
|
||||||
|
if (user.value.realm_access?.roles?.includes(role)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if user has any of the specified roles
|
||||||
|
const hasAnyRole = (roles: string[]): boolean => {
|
||||||
|
return roles.some(role => hasRole(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
user: readonly(user),
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading: readonly(isLoading),
|
||||||
|
error: readonly(error),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkAuth,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
clearError,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
hasRole,
|
||||||
|
hasAnyRole
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
import type { CheckInResult } from '~/types/checkin'
|
||||||
|
|
||||||
|
export interface UseCheckInOptions {
|
||||||
|
showSnackbar: (title: string, message: string, color: string, icon: string) => void
|
||||||
|
saveToHistory: (item: {
|
||||||
|
patientId: string
|
||||||
|
queueNumber?: string
|
||||||
|
status: string
|
||||||
|
checkInTime: string
|
||||||
|
checkInDate: string
|
||||||
|
method: string
|
||||||
|
}) => void
|
||||||
|
saveSuccessfulScan: (qrData: string) => void
|
||||||
|
onCheckInSuccess: (result: {
|
||||||
|
success: boolean
|
||||||
|
patientId: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
action: 'checkin' | 'kembali'
|
||||||
|
}) => void
|
||||||
|
autoCloseDialog: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCheckIn = (options: UseCheckInOptions) => {
|
||||||
|
const { showSnackbar, saveToHistory, saveSuccessfulScan, onCheckInSuccess, autoCloseDialog } = options
|
||||||
|
|
||||||
|
// State
|
||||||
|
const lastCheckInResult = ref<CheckInResult | null>(null)
|
||||||
|
|
||||||
|
// Perform check-in
|
||||||
|
const performCheckIn = async (data: string, method: string = 'QR Scan'): Promise<boolean> => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
const success = Math.random() < 0.8
|
||||||
|
|
||||||
|
// History akan disimpan di processQRCode setelah performCheckIn selesai
|
||||||
|
// Jadi kita hanya perlu return hasil check-in
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process QR code (rename dari onDetect)
|
||||||
|
const processQRCode = async (decodedText: string) => {
|
||||||
|
const [patientId, status] = decodedText.split('|')
|
||||||
|
|
||||||
|
// Validasi format QR code
|
||||||
|
if (!patientId || !status) {
|
||||||
|
showSnackbar('Error', 'QR Code tidak valid. Format harus: ID_PASIEN|STATUS', 'error', 'mdi-close-circle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek apakah pasien diperbolehkan check-in
|
||||||
|
const isAllowed = status === 'ALLOWED'
|
||||||
|
|
||||||
|
if (isAllowed) {
|
||||||
|
// Jika diperbolehkan, langsung proses check-in
|
||||||
|
const checkinSuccess = await performCheckIn(decodedText, 'QR Scan')
|
||||||
|
|
||||||
|
// Simpan hasil check-in
|
||||||
|
lastCheckInResult.value = {
|
||||||
|
success: checkinSuccess,
|
||||||
|
patientId: patientId,
|
||||||
|
status: status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpan ke history check-in dengan status hasil check-in
|
||||||
|
saveToHistory({
|
||||||
|
patientId: patientId || 'Unknown',
|
||||||
|
status: checkinSuccess ? 'success' : 'failed',
|
||||||
|
checkInTime: new Date().toISOString(),
|
||||||
|
checkInDate: new Date().toISOString(),
|
||||||
|
method: 'QR Scan'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tampilkan dialog dengan hasil check-in
|
||||||
|
if (checkinSuccess) {
|
||||||
|
// Simpan QR code yang berhasil di-scan untuk mencegah scan ulang
|
||||||
|
saveSuccessfulScan(decodedText)
|
||||||
|
onCheckInSuccess({
|
||||||
|
success: true,
|
||||||
|
patientId: patientId,
|
||||||
|
status: status,
|
||||||
|
message: `✅ Check-in Berhasil!\n\nPasien ${patientId} berhasil melakukan check-in.`,
|
||||||
|
action: 'checkin'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onCheckInSuccess({
|
||||||
|
success: false,
|
||||||
|
patientId: patientId,
|
||||||
|
status: status,
|
||||||
|
message: `❌ Check-in Gagal!\n\nPasien ${patientId} diperbolehkan check-in, namun proses check-in gagal. Silakan coba lagi.`,
|
||||||
|
action: 'checkin'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
autoCloseDialog()
|
||||||
|
} else {
|
||||||
|
// Jika belum diperbolehkan, tampilkan pesan
|
||||||
|
lastCheckInResult.value = {
|
||||||
|
success: false,
|
||||||
|
patientId: patientId,
|
||||||
|
status: status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpan ke history check-in untuk NOT_ALLOWED
|
||||||
|
saveToHistory({
|
||||||
|
patientId: patientId || 'Unknown',
|
||||||
|
status: 'NOT_ALLOWED',
|
||||||
|
checkInTime: new Date().toISOString(),
|
||||||
|
checkInDate: new Date().toISOString(),
|
||||||
|
method: 'QR Scan'
|
||||||
|
})
|
||||||
|
|
||||||
|
onCheckInSuccess({
|
||||||
|
success: false,
|
||||||
|
patientId: patientId,
|
||||||
|
status: status,
|
||||||
|
message: `⏳ Belum Diizinkan Check-in\n\nAntrean Pasien ${patientId} belum diperbolehkan check-in. Mohon menunggu hingga antrean Anda dipanggil.`,
|
||||||
|
action: 'kembali'
|
||||||
|
})
|
||||||
|
|
||||||
|
autoCloseDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check-in manual
|
||||||
|
const checkInManual = async (
|
||||||
|
patientId: string,
|
||||||
|
onSuccess: () => void,
|
||||||
|
onError: () => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!patientId || !patientId.trim()) {
|
||||||
|
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert')
|
||||||
|
onError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPatientId = patientId.trim()
|
||||||
|
|
||||||
|
// Simulasi check-in manual
|
||||||
|
const success = await performCheckIn(`${trimmedPatientId}|ALLOWED`, 'Manual')
|
||||||
|
|
||||||
|
// Simpan hasil check-in
|
||||||
|
lastCheckInResult.value = {
|
||||||
|
success: success,
|
||||||
|
patientId: trimmedPatientId,
|
||||||
|
status: 'ALLOWED',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpan ke history check-in
|
||||||
|
saveToHistory({
|
||||||
|
patientId: trimmedPatientId,
|
||||||
|
status: success ? 'success' : 'failed',
|
||||||
|
checkInTime: new Date().toISOString(),
|
||||||
|
checkInDate: new Date().toISOString(),
|
||||||
|
method: 'Manual'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Simpan QR code yang berhasil untuk mencegah double antrian (jika menggunakan format yang sama)
|
||||||
|
saveSuccessfulScan(`${trimmedPatientId}|ALLOWED`)
|
||||||
|
showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle')
|
||||||
|
|
||||||
|
// Update info dialog
|
||||||
|
onCheckInSuccess({
|
||||||
|
success: true,
|
||||||
|
patientId: trimmedPatientId,
|
||||||
|
status: 'ALLOWED',
|
||||||
|
message: `✅ Check-in Berhasil!\n\nPasien ${trimmedPatientId} berhasil melakukan check-in secara manual.`,
|
||||||
|
action: 'checkin'
|
||||||
|
})
|
||||||
|
|
||||||
|
autoCloseDialog()
|
||||||
|
onSuccess()
|
||||||
|
} else {
|
||||||
|
showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle')
|
||||||
|
|
||||||
|
// Update info dialog untuk gagal
|
||||||
|
onCheckInSuccess({
|
||||||
|
success: false,
|
||||||
|
patientId: trimmedPatientId,
|
||||||
|
status: 'ALLOWED',
|
||||||
|
message: `❌ Check-in Gagal!\n\nPasien ${trimmedPatientId} gagal melakukan check-in secara manual. Silakan coba lagi.`,
|
||||||
|
action: 'checkin'
|
||||||
|
})
|
||||||
|
|
||||||
|
autoCloseDialog()
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in checkInManual:', error)
|
||||||
|
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert')
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastCheckInResult: readonly(lastCheckInResult),
|
||||||
|
performCheckIn,
|
||||||
|
processQRCode,
|
||||||
|
checkInManual,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user