Compare commits
145
Commits
| 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 |
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"
|
||||
|
||||
@@ -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.
|
||||
@@ -1,21 +1,27 @@
|
||||
# Placeholder API Permission
|
||||
|
||||
## Deskripsi
|
||||
|
||||
Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia.
|
||||
|
||||
## Cara Menggunakan
|
||||
|
||||
### 1. Menggunakan Placeholder API secara Default
|
||||
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.150.131:8089/api/v1/permission`) tidak dapat diakses.
|
||||
|
||||
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
|
||||
```
|
||||
@@ -23,7 +29,9 @@ 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)
|
||||
@@ -32,6 +40,7 @@ Data placeholder mengembalikan 5 permission items:
|
||||
## 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"
|
||||
@@ -40,6 +49,7 @@ Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
|
||||
## 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
|
||||
@@ -50,21 +60,24 @@ Untuk testing dengan user email "bayurssa":
|
||||
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>
|
||||
<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 />
|
||||
</v-app>
|
||||
</NuxtLayout>
|
||||
</NuxtLayout>
|
||||
</v-app>
|
||||
</template>
|
||||
+50
-30
@@ -4,44 +4,46 @@
|
||||
--neutral-800: #4D4D4D;
|
||||
--neutral-700: #717171;
|
||||
--neutral-600: #89939E;
|
||||
--neutral-500: #ABBED1;
|
||||
--neutral-400: #E5F7FA;
|
||||
--neutral-500: #CDD4DC;
|
||||
--neutral-400: #E1E5EA;
|
||||
--neutral-300: #F5F7FA;
|
||||
--neutral-100: #FFFFFF;
|
||||
|
||||
/* Primary Colors (Orange) */
|
||||
--primary-700: #0053AD;
|
||||
--primary-600: #0663C7;
|
||||
--primary-500: #0671E0;
|
||||
--primary-400: #4196F0;
|
||||
--primary-300: #DBEDFF;
|
||||
--primary-200: #EEF5FC;
|
||||
--primary-100: #EEF5FC;
|
||||
/* 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 (Blue) */
|
||||
--secondary-700: #0053AD;
|
||||
--secondary-600: #0671E0;
|
||||
--secondary-500: #0663C7;
|
||||
--secondary-400: #4196F0;
|
||||
--secondary-300: #DBEDFF;
|
||||
--secondary-200: #EEF5FC;
|
||||
--secondary-100: #0053AD;
|
||||
/* 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: #1B6E53;
|
||||
--success-600: #009262;
|
||||
--success-500: #115B43;
|
||||
--success-400: #32C997;
|
||||
--success-300: #84DFC1;
|
||||
--success-200: #F1FBF8;
|
||||
--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: #E01507;
|
||||
--danger-600: #E02B1D;
|
||||
--danger-500: #C33025;
|
||||
--danger-400: #FF5A4F;
|
||||
--danger-300: #F0857D;
|
||||
--danger-200: #FFF1F0;
|
||||
--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 */
|
||||
@@ -68,6 +70,7 @@
|
||||
.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); }
|
||||
@@ -75,6 +78,7 @@
|
||||
.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); }
|
||||
@@ -82,6 +86,7 @@
|
||||
.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); }
|
||||
@@ -95,18 +100,33 @@
|
||||
.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); }
|
||||
|
||||
@@ -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 |
+53
-28
@@ -4,39 +4,44 @@ $neutral-900: #212121;
|
||||
$neutral-800: #4D4D4D;
|
||||
$neutral-700: #717171;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-500: #ABBED1;
|
||||
$neutral-400: #E5F7FA;
|
||||
$neutral-500: #CDD4DC;
|
||||
$neutral-400: #E1E5EA;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-100: #FFFFFF;
|
||||
|
||||
$primary-700: #FF9B1B;
|
||||
$primary-600: #FFA532;
|
||||
$primary-500: #FFB95F;
|
||||
$primary-400: #FFCD8D;
|
||||
$primary-300: #FFDCAF;
|
||||
$primary-200: #FFE6C6;
|
||||
$primary-100: #EEF5FC;
|
||||
$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: #0053AD;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-500: #0663C7;
|
||||
$secondary-400: #4196F0;
|
||||
$secondary-300: #DBEDFF;
|
||||
$secondary-200: #EEF5FC;
|
||||
$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: #1B6E53;
|
||||
$success-600: #009262;
|
||||
$success-500: #115B43;
|
||||
$success-400: #32C997;
|
||||
$success-300: #84DFC1;
|
||||
$success-200: #F1FBF8;
|
||||
$success-700: #008D65;
|
||||
$success-600: #33A484;
|
||||
$success-500: #59B59B;
|
||||
$success-400: #80C6B2;
|
||||
$success-300: #A6D7C9;
|
||||
$success-200: #CCE8E0;
|
||||
$success-100: #E5F4F0;
|
||||
|
||||
$danger-700: #E01507;
|
||||
$danger-600: #E02B1D;
|
||||
$danger-500: #C33025;
|
||||
$danger-400: #FF5A4F;
|
||||
$danger-300: #F0857D;
|
||||
$danger-200: #FFF1F0;
|
||||
$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 {
|
||||
@@ -58,6 +63,7 @@ $danger-200: #FFF1F0;
|
||||
--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};
|
||||
@@ -66,6 +72,8 @@ $danger-200: #FFF1F0;
|
||||
--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};
|
||||
@@ -74,6 +82,7 @@ $danger-200: #FFF1F0;
|
||||
--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};
|
||||
@@ -82,6 +91,7 @@ $danger-200: #FFF1F0;
|
||||
--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};
|
||||
@@ -135,15 +145,30 @@ $colors: (
|
||||
'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-500': $danger-500,
|
||||
'danger-400': $danger-400,
|
||||
'danger-300': $danger-300,
|
||||
'danger-200': $danger-200,
|
||||
'danger-100': $danger-100
|
||||
);
|
||||
|
||||
@each $name, $color in $colors {
|
||||
|
||||
@@ -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; }
|
||||
@@ -1,7 +1,8 @@
|
||||
// Gunakan @use daripada @import
|
||||
@use './variables' as *;
|
||||
@use './colors' as *;
|
||||
@use './typography' as *;
|
||||
@use "./variables" as *;
|
||||
@use "./colors" as *;
|
||||
@use "./typography" as *;
|
||||
@use "./pages/dashboard" as *;
|
||||
|
||||
// Global styles
|
||||
*,
|
||||
@@ -19,7 +20,9 @@ body {
|
||||
padding: 0;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-textPrimary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
// Selection color
|
||||
@@ -68,4 +71,4 @@ button {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
/* assets/styles/loginpage/login.css */
|
||||
/* assets/styles/loginpage/login.scss */
|
||||
@use "~/assets/scss/_colors.scss" as *;
|
||||
|
||||
/* --- COLOR DEFINITIONS --- */
|
||||
/* Base Orange: #FF9B1B (New) */
|
||||
/* Hover/Darker Orange: #E68A00 (Calculated darker shade for hover effect) */
|
||||
/* --- END COLOR DEFINITIONS --- */
|
||||
|
||||
|
||||
/* Main Background - Now Orange */
|
||||
/* Main Background - Using Primary Color */
|
||||
.login-background {
|
||||
background: #FF9B1B; /* Solid Orange Background: #FF9B1B */
|
||||
min-height: 100vh;
|
||||
background: $primary-500; /* Primary-500: #567EE7 */
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background::before {
|
||||
/* No radial gradients */
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -26,7 +21,7 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Floating Medical Icons - Subtle on Orange Background */
|
||||
/* Floating Medical Icons - Subtle on Primary Background */
|
||||
.floating-medical-icon {
|
||||
position: absolute;
|
||||
opacity: 0.8;
|
||||
@@ -45,16 +40,39 @@
|
||||
}
|
||||
|
||||
.floating-medical-icon .v-icon {
|
||||
color: rgba(255, 255, 255, 0.15) !important; /* Light white for icons on orange background */
|
||||
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%; }
|
||||
.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 {
|
||||
@@ -66,14 +84,14 @@
|
||||
}
|
||||
|
||||
.white-card {
|
||||
background: white !important; /* Main card background is white */
|
||||
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 (White) */
|
||||
/* Right Login Section (Neutral-300) */
|
||||
.login-section-box {
|
||||
background: white; /* Changed to white */
|
||||
background: $neutral-300; /* Neutral-300: #F5F7FA */
|
||||
min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */
|
||||
}
|
||||
|
||||
@@ -84,51 +102,51 @@
|
||||
|
||||
/* White Dialog Styles */
|
||||
.white-dialog {
|
||||
background: white !important;
|
||||
background: $neutral-100 !important;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Card Header - Dark Text for white background */
|
||||
/* Card Header - Using Neutral Color with Larger Font */
|
||||
.welcome-title-dark {
|
||||
color: #333; /* Dark text on white background */
|
||||
font-size: 1.1rem;
|
||||
color: $neutral-900; /* Neutral-900: #212121 */
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-instruction-dark {
|
||||
color: #777; /* Grey text for instruction on white background */
|
||||
font-size: 0.8rem;
|
||||
color: $neutral-700; /* Neutral-700: #717171 */
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* App Title - Dark for white background */
|
||||
/* App Title - Using Primary Color */
|
||||
.app-title-dark {
|
||||
color: #FF9B1B; /* Orange app title: #FF9B1B */
|
||||
color: $primary-500; /* Primary-500: #567EE7 */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.hospital-logo {
|
||||
height: 332px;
|
||||
height: 180px;
|
||||
width: auto;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.sso-text-dark {
|
||||
color: #555; /* Dark grey for SSO text */
|
||||
color: $neutral-800; /* Dark grey for SSO text */
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons (Orange theme) */
|
||||
/* Buttons (Primary theme) */
|
||||
.login-btn {
|
||||
background: #FF9B1B !important; /* Solid Orange button: #FF9B1B */
|
||||
color: white !important; /* White text on orange button */
|
||||
background: $primary-500 !important; /* Primary-500: #567EE7 */
|
||||
color: $neutral-100 !important; /* White text on primary button */
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 8px 25px rgba(255, 155, 27, 0.3), /* Orange shadow: #FF9B1B */
|
||||
0 4px 12px rgba(255, 155, 27, 0.2);
|
||||
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;
|
||||
@@ -137,15 +155,15 @@
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
background: #E68A00 !important; /* Slightly darker orange on hover */
|
||||
background: $primary-600 !important; /* Primary-600: #3A61C9 on hover */
|
||||
box-shadow:
|
||||
0 12px 30px rgba(255, 155, 27, 0.4),
|
||||
0 6px 15px rgba(255, 155, 27, 0.3);
|
||||
0 12px 30px rgba(86, 126, 231, 0.4),
|
||||
0 6px 15px rgba(86, 126, 231, 0.3);
|
||||
}
|
||||
|
||||
.register-btn-dark {
|
||||
color: #FF9B1B !important; /* Orange text for register button: #FF9B1B */
|
||||
border: 2px solid #FF9B1B !important; /* Orange border: #FF9B1B */
|
||||
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;
|
||||
@@ -153,39 +171,44 @@
|
||||
}
|
||||
|
||||
.register-btn-dark:hover {
|
||||
background: rgba(255, 155, 27, 0.05) !important; /* Light orange hover background */
|
||||
border-color: #E68A00 !important; /* Darker orange border on hover */
|
||||
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: rgba(0, 0, 0, 0.1) !important; /* Light grey divider */
|
||||
border-color: $neutral-400 !important; /* Light grey divider */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Help Text and Links - Dark for white background */
|
||||
.help-text-dark {
|
||||
color: #555; /* Dark grey help text */
|
||||
color: $neutral-800; /* Dark grey help text */
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.contact-link-dark {
|
||||
color: #FF9B1B !important; /* Orange link: #FF9B1B */
|
||||
color: $primary-500 !important; /* Primary-500: #567EE7 */
|
||||
text-decoration: underline;
|
||||
text-transform: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-link-dark {
|
||||
color: #777; /* Grey link */
|
||||
color: $neutral-700; /* Grey link */
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-link-dark:hover {
|
||||
color: #FF9B1B; /* Orange on hover: #FF9B1B */
|
||||
color: $primary-500; /* Primary-500 on hover */
|
||||
}
|
||||
|
||||
/* Transparent Background */
|
||||
@@ -196,27 +219,26 @@
|
||||
/* Dialog Accents */
|
||||
.v-card-title .v-icon,
|
||||
.v-list-item .v-icon {
|
||||
color: #FF9B1B !important; /* Orange accents: #FF9B1B */
|
||||
color: $primary-500 !important; /* Primary accents */
|
||||
}
|
||||
|
||||
.v-btn[color="#FF9B1B"] {
|
||||
background-color: #FF9B1B !important;
|
||||
color: white !important;
|
||||
background-color: $primary-500 !important;
|
||||
color: $neutral-100 !important;
|
||||
}
|
||||
|
||||
.v-alert[color="orange"] {
|
||||
border-left: 8px solid #FF9B1B !important;
|
||||
color: #333 !important;
|
||||
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;
|
||||
@@ -224,12 +246,11 @@
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -1,457 +1,110 @@
|
||||
<template>
|
||||
<v-card class="pa-6 rounded-xl elevation-4">
|
||||
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
|
||||
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
|
||||
<span>Edit Hak Akses Menu</span>
|
||||
</v-card-title>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-card-text class="px-0">
|
||||
<v-row v-if="localItem.role || localItem.group" class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
||||
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
|
||||
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="backendPermissions.length > 0">
|
||||
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
|
||||
<td class="kol-no text-center">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
|
||||
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
|
||||
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
|
||||
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
|
||||
Level {{ perm.level }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-status">
|
||||
<v-chip
|
||||
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.active"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.read"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.create"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.update"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.delete"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
|
||||
<td class="kol-no text-center">{{ index + 1 }}</td>
|
||||
<td>{{ menu.name }}</td>
|
||||
<td class="text-center kol-status">
|
||||
<v-chip color="success" size="small" variant="tonal">
|
||||
Mapped
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
class="text-capitalize mr-2"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
class="text-capitalize"
|
||||
@click="handleSave"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<v-card 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, ref, watch, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
|
||||
// Define types for better readability and safety
|
||||
interface HakAksesMenu {
|
||||
name: string;
|
||||
canAccess: boolean;
|
||||
canView: boolean;
|
||||
canAdd: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
interface BackendPermissionItem {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface HakAksesData {
|
||||
id: number;
|
||||
role?: string;
|
||||
group?: string;
|
||||
namaTipeUser: string;
|
||||
hakAksesMenu: HakAksesMenu[];
|
||||
}
|
||||
|
||||
// Define props with type validation
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as () => HakAksesData,
|
||||
required: true,
|
||||
},
|
||||
pages: {
|
||||
type: Array as () => any[], // Be flexible with legacy data
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// Define emits for clarity
|
||||
const emits = defineEmits(['save', 'cancel']);
|
||||
const emit = defineEmits(['update:pages']);
|
||||
|
||||
// Use a local copy to avoid mutating the prop directly
|
||||
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
|
||||
const backendPermissions = ref<BackendPermissionItem[]>([]);
|
||||
const isSaving = ref(false);
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Helper function to normalize group name
|
||||
const normalizeGroup = (group: string): string => {
|
||||
const normalized = group.trim();
|
||||
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
|
||||
if (normalized.toLowerCase().includes('instalasi')) {
|
||||
const parts = normalized.split(/\s+/);
|
||||
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
|
||||
if (stimIndex !== -1) {
|
||||
return 'STIM';
|
||||
}
|
||||
}
|
||||
// Jika group adalah "Instalasi STIM", return "STIM"
|
||||
if (normalized.toLowerCase() === 'instalasi stim') {
|
||||
return 'STIM';
|
||||
}
|
||||
return normalized.toUpperCase();
|
||||
};
|
||||
interface FlatNavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Helper function to normalize role name
|
||||
const normalizeRole = (role: string): string => {
|
||||
const normalized = role.toLowerCase().trim();
|
||||
// Mapping khusus untuk role default
|
||||
if (normalized === 'default-roles-sandbox') {
|
||||
return 'superadmin';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API when component mounts
|
||||
onMounted(async () => {
|
||||
if (localItem.value.role && localItem.value.group) {
|
||||
try {
|
||||
// Normalize role and group before making API call
|
||||
const normalizedRole = normalizeRole(localItem.value.role);
|
||||
const normalizedGroup = normalizeGroup(localItem.value.group);
|
||||
|
||||
console.log('🔄 Fetching permissions with normalized values:', {
|
||||
originalRole: localItem.value.role,
|
||||
normalizedRole,
|
||||
originalGroup: localItem.value.group,
|
||||
normalizedGroup,
|
||||
const 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
|
||||
});
|
||||
|
||||
const response = await $fetch<any>('/api/permission', {
|
||||
query: {
|
||||
roles: normalizedRole,
|
||||
groups: normalizedGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log(`✅ Received ${response.data.length} permissions from API`);
|
||||
// Create a deep copy to avoid mutating the original
|
||||
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
||||
...perm,
|
||||
}));
|
||||
} else {
|
||||
console.warn('⚠️ No data received from API or invalid structure');
|
||||
if (item.children && item.children.length > 0) {
|
||||
walk(item.children, level + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching permissions:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Role or group is missing:', {
|
||||
role: localItem.value.role,
|
||||
group: localItem.value.group,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort permissions: level 1 first, then level 2 grouped under their parents
|
||||
const sortedPermissions = computed(() => {
|
||||
if (backendPermissions.value.length === 0) return [];
|
||||
|
||||
const level1 = backendPermissions.value.filter(p => p.level === 1);
|
||||
const level2 = backendPermissions.value.filter(p => p.level === 2);
|
||||
|
||||
// Sort level 1 by sort order
|
||||
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Sort level 2 by sort order
|
||||
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Build result: insert level 2 items after their parent
|
||||
const result: BackendPermissionItem[] = [];
|
||||
|
||||
level1.forEach(parent => {
|
||||
result.push(parent);
|
||||
// Add children of this parent
|
||||
const children = level2.filter(child => child.parent === parent.pagesID);
|
||||
result.push(...children);
|
||||
});
|
||||
|
||||
// Add any remaining level 2 items that don't have a parent match
|
||||
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
|
||||
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
|
||||
result.push(...remainingChildren);
|
||||
};
|
||||
|
||||
walk(navItemsStore.getNavItems);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
|
||||
const isPageMapped = (pagename: string): boolean => {
|
||||
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
|
||||
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 lowerPage = (pagename || '').toLowerCase();
|
||||
return localItem.value.hakAksesMenu.some((menu) => {
|
||||
const name = (menu.name || '').toLowerCase();
|
||||
return (
|
||||
name === lowerPage ||
|
||||
name.includes(lowerPage) ||
|
||||
lowerPage.includes(name)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface NavItemOrder {
|
||||
name: string;
|
||||
children?: NavItemOrder[];
|
||||
}
|
||||
|
||||
// Build a map of menu order based on the sidebar configuration so the list is always aligned
|
||||
const menuOrder = computed(() => {
|
||||
const order: Record<string, number> = {};
|
||||
const walk = (items: NavItemOrder[], startIndex = 0): number => {
|
||||
let idx = startIndex;
|
||||
items.forEach((item) => {
|
||||
order[item.name] = idx;
|
||||
idx += 1;
|
||||
if (item.children?.length) {
|
||||
idx = walk(item.children, idx);
|
||||
}
|
||||
});
|
||||
return idx;
|
||||
};
|
||||
walk(navItemsStore.navItems);
|
||||
return order;
|
||||
});
|
||||
|
||||
const orderedMenus = computed(() => {
|
||||
const order = menuOrder.value;
|
||||
return [...localItem.value.hakAksesMenu].sort((a, b) => {
|
||||
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle save - convert backend permissions back to menu structure if needed
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
// If we have backend permissions, we need to map them back to the menu structure
|
||||
if (backendPermissions.value.length > 0) {
|
||||
// Update the local item with backend permissions mapped to menu structure
|
||||
const updatedItem = {
|
||||
...localItem.value,
|
||||
backendPermissions: backendPermissions.value,
|
||||
};
|
||||
emits('save', updatedItem);
|
||||
} else {
|
||||
// Use existing menu structure
|
||||
emits('save', localItem.value);
|
||||
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
|
||||
let newPages = [...props.pages];
|
||||
|
||||
if (allowed) {
|
||||
if (!newPages.includes(item.path)) {
|
||||
newPages.push(item.path);
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
} else {
|
||||
newPages = newPages.filter(p => p !== item.path);
|
||||
}
|
||||
|
||||
emit('update:pages', newPages);
|
||||
};
|
||||
|
||||
// Watch the prop for changes and update the local copy
|
||||
watch(() => props.item, (newItem) => {
|
||||
localItem.value = JSON.parse(JSON.stringify(newItem));
|
||||
// Re-fetch permissions if role/group changed
|
||||
if (newItem.role && newItem.group) {
|
||||
// Normalize role and group before making API call
|
||||
const normalizedRole = normalizeRole(newItem.role);
|
||||
const normalizedGroup = normalizeGroup(newItem.group);
|
||||
|
||||
console.log('🔄 Re-fetching permissions with normalized values:', {
|
||||
originalRole: newItem.role,
|
||||
normalizedRole,
|
||||
originalGroup: newItem.group,
|
||||
normalizedGroup,
|
||||
});
|
||||
|
||||
$fetch<any>('/api/permission', {
|
||||
query: {
|
||||
roles: normalizedRole,
|
||||
groups: normalizedGroup,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log(`✅ Received ${response.data.length} permissions from API`);
|
||||
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
||||
...perm,
|
||||
}));
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('❌ Error fetching permissions:', error);
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-table :deep(th) {
|
||||
font-weight: bold !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.v-table :deep(td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-checkbox :deep(.v-selection-control__input) {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-no) {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-aksi) {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-status) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.cek-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.bg-grey-lighten-4 {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,30 @@
|
||||
<template>
|
||||
<v-card
|
||||
class="pa-4 rounded-lg elevation-2 d-flex flex-column"
|
||||
class="rounded-lg elevation-2 d-flex flex-column ticket-card"
|
||||
color="white"
|
||||
style="border-top: 8px solid #FFB95F;"
|
||||
height="100%"
|
||||
>
|
||||
<div class="d-flex align-center justify-center mb-4">
|
||||
<v-icon size="36" color="orange-lighten-2" class="mr-2">mdi-hospital-box-outline</v-icon>
|
||||
<div class="text-h6 font-weight-bold text-orange-lighten-2">{{ title }}</div>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<!-- Content -->
|
||||
<v-card-text class="ticket-content pa-4">
|
||||
|
||||
<div class="timeline-scroll-container">
|
||||
<v-timeline density="compact" align="start" line-inset="12" line-color="#FFB95F">
|
||||
<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"
|
||||
@@ -24,12 +35,12 @@
|
||||
>
|
||||
<div class="d-flex flex-column align-start">
|
||||
<span
|
||||
class="font-weight-bold"
|
||||
:class="index === currentStepIndex ? 'text-orange-lighten-1' : index < currentStepIndex ? 'text-success' : 'text-grey-darken-1'"
|
||||
class="font-weight-bold"
|
||||
:class="getStepTextClass(index)"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
<span class="text-caption text-grey-darken-1">
|
||||
<span class="text-caption" :class="getStepDateClass(index)">
|
||||
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -42,13 +53,14 @@
|
||||
<v-btn
|
||||
size="large"
|
||||
variant="tonal"
|
||||
color="#FFA532"
|
||||
class="mt-auto font-weight-bold"
|
||||
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>
|
||||
|
||||
@@ -59,13 +71,30 @@ const props = defineProps({
|
||||
title: String,
|
||||
steps: Array,
|
||||
color: String,
|
||||
currentStepLabel: 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';
|
||||
@@ -77,22 +106,47 @@ const getStepIcon = (index) => {
|
||||
};
|
||||
|
||||
const getStepColor = (index) => {
|
||||
// Warna berbeda berdasarkan status step (dari _colors.scss)
|
||||
if (index < currentStepIndex.value) {
|
||||
return 'success';
|
||||
return '#33A484'; // success-600 - hijau untuk completed
|
||||
} else if (index === currentStepIndex.value) {
|
||||
return 'orange-lighten-1';
|
||||
return '#FF8441'; // secondary-500 - orange untuk current
|
||||
}
|
||||
return 'grey-lighten-1';
|
||||
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>
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/scss/_colors.scss';
|
||||
|
||||
/* =============================================== */
|
||||
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
|
||||
/* =============================================== */
|
||||
.timeline-scroll-container {
|
||||
/* Tinggi Tetap: Memastikan tinggi card konsisten di semua skenario responsive */
|
||||
height: 320px;
|
||||
/* 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;
|
||||
@@ -127,4 +181,69 @@ const getStepColor = (index) => {
|
||||
.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)
|
||||
})
|
||||
})
|
||||
*/
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-caption patient-info-label mb-0">Waktu Scan</p>
|
||||
<p class="text-body-2 patient-info-value mb-0">{{ new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) }}</p>
|
||||
<p class="text-body-2 patient-info-value mb-0">{{ getCurrentTime() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -164,6 +164,16 @@ 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">
|
||||
|
||||
@@ -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,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>
|
||||
@@ -3,7 +3,16 @@
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">{{ icon }}</v-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>
|
||||
@@ -33,6 +42,10 @@ defineProps({
|
||||
type: String,
|
||||
default: 'mdi-view-dashboard'
|
||||
},
|
||||
logo: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -51,8 +64,8 @@ defineProps({
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'primary', // 'primary', 'secondary', 'success', or 'accent'
|
||||
validator: (value) => ['primary', 'secondary', 'success', 'accent'].includes(value)
|
||||
default: 'primary', // 'primary', 'secondary', 'success', 'accent', or 'warning'
|
||||
validator: (value) => ['primary', 'secondary', 'success', 'accent', 'warning'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,49 +73,48 @@ defineEmits(['add-click']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$neutral-100: #FFFFFF;
|
||||
$primary-600: #FFA532;
|
||||
$primary-700: #FF9B1B;
|
||||
$secondary-600: #0671E0;
|
||||
$secondary-700: #0053AD;
|
||||
$success-600: #009262;
|
||||
$success-700: #1B6E53;
|
||||
$accent-600: #8B5CF6;
|
||||
$accent-700: #7C3AED;
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
.page-header {
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.page-header-primary {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
|
||||
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, $secondary-600 0%, $secondary-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(6, 113, 224, 0.2);
|
||||
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, $success-600 0%, $success-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
|
||||
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 {
|
||||
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
|
||||
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3);
|
||||
// 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: 32px;
|
||||
color: $neutral-100;
|
||||
padding: 16px 28px;
|
||||
height: 80px;
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -112,28 +124,41 @@ $font-weight-semibold: 600;
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
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: 36px;
|
||||
line-height: 44px;
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
color: $neutral-100;
|
||||
color: var(--color-neutral-100);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
margin: 2px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $neutral-100;
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
@@ -145,18 +170,24 @@ $font-weight-semibold: 600;
|
||||
}
|
||||
|
||||
.add-btn-primary {
|
||||
color: $primary-600 !important;
|
||||
color: var(--color-primary-600) !important;
|
||||
}
|
||||
|
||||
.add-btn-secondary {
|
||||
color: $secondary-600 !important;
|
||||
color: var(--color-secondary-600) !important;
|
||||
}
|
||||
|
||||
.add-btn-success {
|
||||
color: $success-600 !important;
|
||||
color: var(--color-success-600) !important;
|
||||
}
|
||||
|
||||
.add-btn-accent {
|
||||
color: $accent-600 !important;
|
||||
// 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>
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="800px">
|
||||
<v-card>
|
||||
<v-dialog v-model="dialogModel" max-width="800px" persistent>
|
||||
<v-card class="dialog-card">
|
||||
<v-card-title class="dialog-header">
|
||||
<v-btn icon size="small" @click="dialogModel = false">
|
||||
<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>
|
||||
<span>{{ title }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="dialog-content">
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
:placeholder="searchPlaceholder"
|
||||
@@ -94,32 +96,62 @@ const handleSelect = (item) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dialog-card {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
background: var(--color-neutral-300);
|
||||
padding: 16px 20px;
|
||||
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;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 2px solid var(--color-neutral-500);
|
||||
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-200);
|
||||
background: var(--color-secondary-300);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(6, 113, 224, 0.15);
|
||||
}
|
||||
|
||||
.selection-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
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,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>
|
||||
@@ -22,7 +22,7 @@
|
||||
:rules="[v => !!v || 'Nama loket harus diisi']"
|
||||
hide-details="auto"
|
||||
class="mb-3 input-field"
|
||||
placeholder="Loket 1"
|
||||
placeholder="Loket A"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
|
||||
@@ -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>
|
||||
@@ -1,32 +1,118 @@
|
||||
<template>
|
||||
<v-card class="current-patient-card" elevation="0">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="section-label mb-3">SEDANG DIPROSES</div>
|
||||
<div 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-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div>
|
||||
<div class="patient-info-text mb-3">
|
||||
<div>{{ patient.barcode }}</div>
|
||||
<div>{{ patient.klinik }} | {{ patient.pembayaran }}</div>
|
||||
<div 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="action-grid">
|
||||
<v-btn class="text-white" color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')">
|
||||
<v-icon start size="20">mdi-check</v-icon>
|
||||
Selesai
|
||||
</v-btn>
|
||||
<v-btn class="text-white" color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')">
|
||||
<v-icon start size="20">mdi-clock-alert</v-icon>
|
||||
Terlambat
|
||||
</v-btn>
|
||||
<v-btn class="text-white" color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')">
|
||||
<v-icon start size="20">mdi-pause</v-icon>
|
||||
Pending
|
||||
</v-btn>
|
||||
<v-btn class="text-white" color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')">
|
||||
<v-icon start size="20">mdi-swap-horizontal</v-icon>
|
||||
{{ changeButtonText }}
|
||||
</v-btn>
|
||||
<div 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>
|
||||
|
||||
@@ -56,7 +142,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
@@ -80,7 +168,13 @@ defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
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">
|
||||
@@ -90,6 +184,12 @@ defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -98,13 +198,32 @@ defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
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: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.patient-details-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary-200) 0%, var(--color-primary-300) 100%);
|
||||
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-primary-100) 100%);
|
||||
}
|
||||
|
||||
.patient-details-secondary {
|
||||
@@ -115,24 +234,204 @@ defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
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: 28px;
|
||||
font-weight: 800;
|
||||
font-size: 32px;
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--color-neutral-700);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
.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 {
|
||||
@@ -140,8 +439,28 @@ defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
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: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -156,13 +475,36 @@ defineEmits(['action', 'change-klinik', 'process-next']);
|
||||
|
||||
.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) {
|
||||
.action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.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>
|
||||
@@ -1,57 +1,85 @@
|
||||
<template>
|
||||
<v-tooltip
|
||||
:text="isClickable ? 'Klik untuk proses pasien' : ''"
|
||||
location="top"
|
||||
:disabled="!isClickable"
|
||||
<v-card
|
||||
class="patient-card"
|
||||
elevation="2"
|
||||
:class="{
|
||||
'clickable-card': isClickable,
|
||||
'fast-track-card': isFastTrack && !isCurrentlyProcessing,
|
||||
'processing-card': isCurrentlyProcessing || patient.status === 'diproses'
|
||||
}"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-card
|
||||
class="patient-card"
|
||||
elevation="2"
|
||||
:class="{ 'clickable-card': isClickable }"
|
||||
@click="handleCardClick"
|
||||
v-bind="isClickable ? tooltipProps : {}"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<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>
|
||||
<v-icon
|
||||
v-if="patient.fastTrack === 'YA'"
|
||||
color="warning"
|
||||
size="20"
|
||||
class="fast-track-icon"
|
||||
<div
|
||||
v-if="isFastTrack"
|
||||
class="fast-track-icon-wrapper"
|
||||
>
|
||||
mdi-flash
|
||||
</v-icon>
|
||||
<v-icon
|
||||
color="#E53935"
|
||||
size="14"
|
||||
class="fast-track-icon"
|
||||
>
|
||||
mdi-flash
|
||||
</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
<v-chip
|
||||
:color="getStatusColor(patient.status)"
|
||||
size="small"
|
||||
class="status-chip"
|
||||
>
|
||||
{{ getStatusLabel(patient.status) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Patient Info Grid - Simplified -->
|
||||
<div class="patient-info mt-3">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Jam Panggil:</span>
|
||||
<span class="info-value">{{ patient.jamPanggil }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Klinik:</span>
|
||||
<v-chip size="small" variant="outlined" class="klinik-chip">
|
||||
{{ patient.klinik }}
|
||||
<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>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Pembayaran:</span>
|
||||
<span class="info-value">{{ patient.pembayaran }}</span>
|
||||
<!-- 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>
|
||||
|
||||
@@ -70,13 +98,14 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useQueueStore } from '~/stores/queueStore';
|
||||
|
||||
const queueStore = useQueueStore();
|
||||
|
||||
const props = defineProps({
|
||||
patient: {
|
||||
@@ -88,9 +117,68 @@ const props = defineProps({
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const isClickable = computed(() => {
|
||||
return props.patient.status === 'diloket' || props.patient.status === 'pending';
|
||||
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');
|
||||
@@ -99,17 +187,17 @@ const handleCardClick = () => {
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
diloket: "var(--color-secondary-600)",
|
||||
diproses: "var(--color-primary-600)",
|
||||
terlambat: "var(--color-primary-600)",
|
||||
pending: "var(--color-danger-600)"
|
||||
'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 = {
|
||||
diloket: "Di Loket",
|
||||
'di-loket': "Di Loket",
|
||||
diproses: "Diproses",
|
||||
terlambat: "Terlambat",
|
||||
pending: "Pending"
|
||||
@@ -145,6 +233,28 @@ const getStatusLabel = (status) => {
|
||||
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 {
|
||||
@@ -167,17 +277,24 @@ const getStatusLabel = (status) => {
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.fast-track-icon {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
.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);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fast-track-icon-wrapper .fast-track-icon {
|
||||
color: #E53935 !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
@@ -189,46 +306,45 @@ const getStatusLabel = (status) => {
|
||||
.patient-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
/* Tinggi dinamis berdasarkan konten - card tanpa dokter akan lebih compact */
|
||||
}
|
||||
|
||||
.info-row {
|
||||
.info-chips-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.payment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fast-track-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 500;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--color-neutral-900);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.klinik-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1.5px solid currentColor;
|
||||
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 {
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<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"
|
||||
@@ -20,7 +24,8 @@
|
||||
rounded
|
||||
class="mt-1"
|
||||
/>
|
||||
<span class="quota-used">Terpakai: {{ usedQuota }}</span>
|
||||
<span class="quota-used">Selesai: {{ usedQuota }}</span>
|
||||
<span class="quota-callable">Bisa dipanggil: {{ callableQuota }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +58,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
menungguCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
hasNext: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -61,6 +70,11 @@ const props = defineProps({
|
||||
|
||||
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>
|
||||
@@ -70,6 +84,18 @@ defineEmits(['call']);
|
||||
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 {
|
||||
@@ -81,20 +107,37 @@ defineEmits(['call']);
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
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);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
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 / -1;
|
||||
grid-column: 1 / 4 !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.quota-label {
|
||||
@@ -107,15 +150,29 @@ defineEmits(['call']);
|
||||
|
||||
.quota-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
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;
|
||||
@@ -131,13 +188,40 @@ defineEmits(['call']);
|
||||
|
||||
.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>
|
||||
@@ -18,16 +18,6 @@
|
||||
{{ status.label }} ({{ status.count }})
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
|
||||
<!-- Search Field -->
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
placeholder="Cari barcode, nomor antrian..."
|
||||
density="compact"
|
||||
hide-details
|
||||
class="search-field"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
@@ -126,25 +116,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div v-if="filteredAndSearchedItems.length > 0" class="results-info mb-3">
|
||||
<span class="results-text">
|
||||
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
|
||||
</span>
|
||||
</div>
|
||||
<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 v-if="filteredAndSearchedItems.length > 0" class="patient-grid">
|
||||
<PatientCard
|
||||
v-for="(patient, index) in paginatedItems"
|
||||
:key="`${patient.barcode}-${index}`"
|
||||
:patient="patient"
|
||||
@action="handleAction"
|
||||
/>
|
||||
<!-- 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">
|
||||
<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' }}
|
||||
@@ -200,13 +192,17 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
anjunganCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showDiproses: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 9
|
||||
default: 12
|
||||
},
|
||||
statusLabels: {
|
||||
type: Object,
|
||||
@@ -281,7 +277,7 @@ const searchModel = computed({
|
||||
const statusOptions = computed(() => {
|
||||
const baseOptions = [
|
||||
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
|
||||
{ value: 'diloket', label: props.statusLabels.diloket, count: props.diLoketCount }
|
||||
{ value: 'di-loket', label: props.statusLabels.diloket, count: props.diLoketCount }
|
||||
];
|
||||
|
||||
// Tampilkan "Diproses" hanya jika:
|
||||
@@ -295,6 +291,16 @@ const statusOptions = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -459,11 +465,6 @@ const handleAction = (patient, action) => {
|
||||
border-color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.advanced-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -486,7 +487,11 @@ const handleAction = (patient, action) => {
|
||||
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 {
|
||||
@@ -543,11 +548,6 @@ const handleAction = (patient, action) => {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.advanced-filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -17,10 +17,10 @@
|
||||
link
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="avatar-wrapper">
|
||||
<v-avatar size="44" class="avatar-glow">
|
||||
<div class="profile-avatar-container">
|
||||
<v-avatar size="44" class="profile-avatar elevation-8">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:src="getAvatarSrc(user?.picture)"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
@@ -41,10 +41,10 @@
|
||||
class="avatar-btn"
|
||||
:title="user?.name || 'Profile'"
|
||||
>
|
||||
<div class="avatar-wrapper">
|
||||
<v-avatar size="44" class="avatar-glow">
|
||||
<div class="profile-avatar-container">
|
||||
<v-avatar size="44" class="profile-avatar elevation-8">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:src="getAvatarSrc(user?.picture)"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
@@ -58,10 +58,10 @@
|
||||
<div class="card-header">
|
||||
<div class="header-pattern"></div>
|
||||
<div class="header-content pa-6 text-center">
|
||||
<div class="avatar-container mb-4">
|
||||
<v-avatar size="90" class="profile-avatar">
|
||||
<div class="profile-avatar-container mb-4">
|
||||
<v-avatar size="90" class="profile-avatar elevation-8">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:src="getAvatarSrc(user?.picture)"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
@@ -93,34 +93,10 @@
|
||||
@click="handleAction('profile')"
|
||||
>
|
||||
<div class="tile-icon-wrapper mb-2 mx-auto">
|
||||
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon>
|
||||
<v-icon size="24" color="primary-600">mdi-account-circle</v-icon>
|
||||
</div>
|
||||
<div class="text-caption font-weight-medium">Profil</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
|
||||
@click="handleAction('account')"
|
||||
>
|
||||
<div class="tile-icon-wrapper mb-2 mx-auto">
|
||||
<v-icon size="24" color="orange-darken-2">mdi-cog-outline</v-icon>
|
||||
</div>
|
||||
<div class="text-caption font-weight-medium">Pengaturan</div>
|
||||
</div>
|
||||
|
||||
<!-- <div
|
||||
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
|
||||
@click="handleAction('darkMode')"
|
||||
>
|
||||
<div class="tile-icon-wrapper mb-2 mx-auto">
|
||||
<v-icon size="24" :color="darkMode ? 'deep-purple-darken-2' : 'amber-darken-2'">
|
||||
{{ darkMode ? 'mdi-moon-waning-crescent' : 'mdi-white-balance-sunny' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
<div class="text-caption font-weight-medium">
|
||||
{{ darkMode ? 'Gelap' : 'Terang' }}
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
@@ -146,6 +122,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { navigateTo } from '#app';
|
||||
import { DEFAULT_AVATAR } from '@/constants/avatar';
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
@@ -159,10 +136,13 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const menu = ref(false);
|
||||
const darkMode = 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;
|
||||
@@ -177,41 +157,38 @@ const signOut = async () => {
|
||||
|
||||
const handleAction = (action) => {
|
||||
switch(action) {
|
||||
case 'account':
|
||||
navigateTo('/Profile/Pengaturan')
|
||||
break;
|
||||
case 'profile':
|
||||
navigateTo('/Profile/Profil')
|
||||
break;
|
||||
case 'darkMode':
|
||||
darkMode.value = !darkMode.value;
|
||||
return;
|
||||
}
|
||||
|
||||
menu.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/scss/_colors.scss';
|
||||
|
||||
.profile-activator {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
|
||||
border: 1px solid rgba(25, 118, 210, 0.2);
|
||||
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, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
|
||||
border-color: rgba(25, 118, 210, 0.3);
|
||||
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
|
||||
border-color: $primary-300;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
.profile-avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.avatar-glow {
|
||||
.profile-avatar {
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -220,7 +197,7 @@ const handleAction = (action) => {
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
|
||||
background: linear-gradient(135deg, $success-600 0%, $success-400 100%);
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
@@ -251,12 +228,12 @@ const handleAction = (action) => {
|
||||
|
||||
.profile-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(25, 118, 210, 0.1);
|
||||
border: 1px solid $primary-200;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
|
||||
background: linear-gradient(135deg, $primary-700 0%, $primary-500 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -276,15 +253,6 @@ const handleAction = (action) => {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
@@ -292,7 +260,7 @@ const handleAction = (action) => {
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
|
||||
background: linear-gradient(135deg, $success-600 0%, $success-400 100%);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
@@ -310,20 +278,20 @@ const handleAction = (action) => {
|
||||
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-tile {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
|
||||
border: 1px solid rgba(25, 118, 210, 0.1);
|
||||
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, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
|
||||
border-color: rgba(25, 118, 210, 0.3);
|
||||
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);
|
||||
}
|
||||
@@ -351,7 +319,7 @@ const handleAction = (action) => {
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
|
||||
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@@ -359,11 +327,11 @@ const handleAction = (action) => {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(222, 71, 59, 0.3);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
|
||||
box-shadow: 0 6px 20px rgba(222, 71, 59, 0.4);
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
|
||||
+461
-45
@@ -9,20 +9,28 @@
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<v-list-item class="py-4 px-2" :class="{ 'text-center': rail }">
|
||||
<div class="d-flex align-center" :class="{ 'justify-center': rail }">
|
||||
<img
|
||||
src="/Antrean Logo.png"
|
||||
alt="Antrean Logo"
|
||||
style="width: 72px; height: 72px; object-fit: contain"
|
||||
/>
|
||||
<v-list-item-title
|
||||
v-if="!rail"
|
||||
class="ml-2 text-h6 font-weight-bold text-blue-grey-darken-4"
|
||||
>
|
||||
<span class="text-orange-darken-2">Antrean</span> RSSA
|
||||
</v-list-item-title>
|
||||
</div>
|
||||
<v-list-item 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>
|
||||
|
||||
@@ -33,33 +41,92 @@
|
||||
v-model:opened="openedGroups"
|
||||
>
|
||||
<template v-for="item in items" :key="item.name">
|
||||
<v-list-group v-if="item.children" :value="item.name" :disabled="rail">
|
||||
<!-- 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"
|
||||
color="orange-darken-2"
|
||||
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
|
||||
class="sidebar-nav-item"
|
||||
active-class="sidebar-nav-item-active"
|
||||
></v-list-item>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
<v-tooltip
|
||||
v-for="child in item.children"
|
||||
:key="child.name"
|
||||
:to="child.path"
|
||||
:title="child.name"
|
||||
:prepend-icon="child.icon"
|
||||
link
|
||||
class="pl-8"
|
||||
color="orange-darken-2"
|
||||
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
|
||||
></v-list-item>
|
||||
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
|
||||
:disabled="!rail"
|
||||
open-on-hover
|
||||
location="end"
|
||||
:text="item.name"
|
||||
@@ -71,8 +138,8 @@
|
||||
:title="item.name"
|
||||
:to="item.path"
|
||||
link
|
||||
color="orange-darken-2"
|
||||
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
|
||||
class="sidebar-nav-item"
|
||||
active-class="sidebar-nav-item-active"
|
||||
></v-list-item>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
@@ -81,23 +148,55 @@
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-list v-if="user" nav density="compact" class="pa-2 flex-shrink-0">
|
||||
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
|
||||
</v-list>
|
||||
<v-list v-else nav density="compact" class="flex-shrink-0">
|
||||
<v-list-item
|
||||
@click="redirectToLogin"
|
||||
prepend-icon="mdi-login"
|
||||
title="Login"
|
||||
link
|
||||
color="blue-grey-darken-4"
|
||||
></v-list-item>
|
||||
</v-list>
|
||||
<!-- 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 { defineProps, defineEmits, onMounted, ref, watch } from "vue";
|
||||
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";
|
||||
|
||||
@@ -140,9 +239,15 @@ watch(
|
||||
);
|
||||
|
||||
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) {
|
||||
if (props.rail && !isLocked.value) {
|
||||
isHovering.value = true;
|
||||
emit("toggle-rail");
|
||||
}
|
||||
@@ -151,10 +256,30 @@ const handleMouseEnter = () => {
|
||||
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 () => {
|
||||
@@ -175,6 +300,297 @@ onMounted(async () => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No specific overrides needed */
|
||||
<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>
|
||||
@@ -1,83 +1,93 @@
|
||||
<template>
|
||||
<v-card-text class="pa-0 bg-white">
|
||||
<v-list lines="two" class="pa-0">
|
||||
<v-list-item
|
||||
v-for="patient in patients"
|
||||
:key="patient.rm"
|
||||
class="patient-item"
|
||||
:class="{ 'verified-item': patient.status === 'Terverifikasi' }"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar
|
||||
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
|
||||
size="64"
|
||||
class="patient-avatar"
|
||||
>
|
||||
<v-icon size="36" color="white">
|
||||
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-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>
|
||||
|
||||
<v-list-item-title class="patient-name">
|
||||
{{ patient.nama }}
|
||||
</v-list-item-title>
|
||||
<!-- Nama -->
|
||||
<td class="table-cell">
|
||||
<span class="patient-name">{{ patient.nama }}</span>
|
||||
</td>
|
||||
|
||||
<v-list-item-subtitle class="mt-2">
|
||||
<v-row dense class="patient-info">
|
||||
<v-col cols="12" sm="3" md="2" class="py-1">
|
||||
<v-chip size="default" color="primary-200" class="chip-rm" variant="flat">
|
||||
<v-icon start size="18" color="secondary-600">mdi-file-document</v-icon>
|
||||
<span class="chip-rm-text">{{ patient.rm }}</span>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="5" md="6" class="py-1 info-item">
|
||||
<v-icon size="18" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
|
||||
<!-- 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>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4" md="4" class="py-1 info-item">
|
||||
<v-icon size="18" class="mr-2" color="neutral-600">mdi-phone</v-icon>
|
||||
<span>{{ patient.telepon || 'Belum diisi' }}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="patient.status === 'Belum Terverifikasi'"
|
||||
color="primary-600"
|
||||
size="x-large"
|
||||
@click="$emit('verify', patient)"
|
||||
prepend-icon="mdi-qrcode-scan"
|
||||
variant="flat"
|
||||
class="action-btn"
|
||||
rounded="xl"
|
||||
>
|
||||
VERIFIKASI
|
||||
</v-btn>
|
||||
|
||||
<v-chip
|
||||
v-else
|
||||
color="secondary-600"
|
||||
size="x-large"
|
||||
variant="flat"
|
||||
class="verified-chip"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-icon start size="24">mdi-shield-check</v-icon>
|
||||
VERIFIED
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="patients.length === 0">
|
||||
<v-list-item-title class="empty-state">
|
||||
{{ emptyMessage }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<!-- 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>
|
||||
|
||||
@@ -99,12 +109,14 @@ defineEmits(['verify']);
|
||||
<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;
|
||||
@@ -116,63 +128,129 @@ $font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
$font-weight-extra-bold: 800;
|
||||
|
||||
.patient-item {
|
||||
border-bottom: 1px solid $neutral-400;
|
||||
padding: 24px;
|
||||
.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;
|
||||
overflow: hidden;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
.patient-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
background: $primary-600;
|
||||
transform: scaleY(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.patient-item:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.patient-item:hover {
|
||||
background: $primary-100 !important;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.verified-item {
|
||||
background: $secondary-200 !important;
|
||||
}
|
||||
|
||||
.verified-item::before {
|
||||
background: $secondary-600 !important;
|
||||
}
|
||||
|
||||
.verified-item:hover {
|
||||
background: $secondary-300 !important;
|
||||
.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: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: $font-weight-extra-bold;
|
||||
color: $neutral-900;
|
||||
margin-bottom: 8px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
font-size: 14px;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
@@ -195,51 +273,93 @@ $font-weight-extra-bold: 800;
|
||||
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: 16px;
|
||||
color: $neutral-100;
|
||||
padding: 12px 32px;
|
||||
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.25);
|
||||
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;
|
||||
}
|
||||
|
||||
.verified-chip {
|
||||
.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: 16px;
|
||||
color: $neutral-100;
|
||||
padding: 12px 24px;
|
||||
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 {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
font-size: 18px;
|
||||
color: $neutral-600;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RESPONSIVE
|
||||
// ============================================
|
||||
@media (max-width: 960px) {
|
||||
.patient-item {
|
||||
padding: 20px;
|
||||
.patient-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 14px;
|
||||
padding: 10px 20px;
|
||||
.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: 56px !important;
|
||||
height: 56px !important;
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</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,
|
||||
};
|
||||
};
|
||||
|
||||
+21
-8
@@ -16,19 +16,32 @@ export const useAuth = () => {
|
||||
isLoading.value = true
|
||||
clearError()
|
||||
|
||||
const response = await $fetch<SessionResponse>('/api/auth/session')
|
||||
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
|
||||
|
||||
if (response.success === false && response.error) {
|
||||
error.value = response.error
|
||||
user.value = null
|
||||
return null
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
error.value = 'Failed to check authentication status'
|
||||
// 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 {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useCheckInHistory = () => {
|
||||
data: qrData,
|
||||
timestamp: new Date().toISOString(),
|
||||
date: new Date().toLocaleDateString('id-ID'),
|
||||
time: new Date().toLocaleTimeString('id-ID')
|
||||
time: new Date().toLocaleTimeString('id-ID').replace(/\./g, ':')
|
||||
})
|
||||
|
||||
// Simpan maksimal item sesuai constant
|
||||
@@ -227,11 +227,13 @@ export const useCheckInHistory = () => {
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleTimeString('id-ID', {
|
||||
// Format jam dengan titik dua (HH:MM:SS)
|
||||
const timeString = date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
return timeString.replace(/\./g, ':')
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// composables/useClinicAPI.ts
|
||||
// Composable untuk API calls terkait klinik/poli
|
||||
|
||||
import { useAPI } from './useAPI';
|
||||
|
||||
export interface Clinic {
|
||||
id: number;
|
||||
kode: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
shift?: string;
|
||||
schedule?: string;
|
||||
available?: boolean;
|
||||
doctors?: string[];
|
||||
shifts?: Array<{ name: string; quota: number }>;
|
||||
totalQuota?: number;
|
||||
jamShiftList?: Array<{ dari: string; sampai: string; kuota: number }>;
|
||||
autoShift?: boolean;
|
||||
jadwalKlinik?: string[];
|
||||
}
|
||||
|
||||
export const useClinicAPI = () => {
|
||||
const api = useAPI();
|
||||
|
||||
/**
|
||||
* Fetch all clinics from database
|
||||
*/
|
||||
const fetchAllClinics = async (): Promise<Clinic[]> => {
|
||||
try {
|
||||
return await api.get<Clinic[]>('/clinics');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching clinics:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch single clinic by ID
|
||||
*/
|
||||
const fetchClinic = async (id: number): Promise<Clinic | null> => {
|
||||
try {
|
||||
return await api.get<Clinic>(`/clinics/${id}`);
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching clinic:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch clinics by kode
|
||||
*/
|
||||
const fetchClinicByKode = async (kode: string): Promise<Clinic | null> => {
|
||||
try {
|
||||
return await api.get<Clinic>(`/clinics/kode/${kode}`);
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching clinic by kode:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new clinic
|
||||
*/
|
||||
const createClinic = async (clinicData: Partial<Clinic>): Promise<Clinic> => {
|
||||
try {
|
||||
return await api.post<Clinic>('/clinics', clinicData);
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error creating clinic:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update clinic
|
||||
*/
|
||||
const updateClinic = async (id: number, updates: Partial<Clinic>): Promise<Clinic> => {
|
||||
try {
|
||||
return await api.patch<Clinic>(`/clinics/${id}`, updates);
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error updating clinic:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete clinic
|
||||
*/
|
||||
const deleteClinic = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
await api.delete(`/clinics/${id}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error deleting clinic:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get clinics for dropdown (formatted for dropdown use)
|
||||
*/
|
||||
const fetchClinicsForDropdown = async (): Promise<Clinic[]> => {
|
||||
try {
|
||||
const clinics = await fetchAllClinics();
|
||||
// Format sesuai kebutuhan dropdown
|
||||
return clinics.map(clinic => ({
|
||||
id: clinic.id,
|
||||
name: clinic.name,
|
||||
kode: clinic.kode,
|
||||
icon: clinic.icon,
|
||||
available: clinic.available ?? true,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching clinics for dropdown:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync local clinics with database
|
||||
*/
|
||||
const syncWithDatabase = async (localClinics: Clinic[]): Promise<Clinic[]> => {
|
||||
try {
|
||||
const dbClinics = await fetchAllClinics();
|
||||
|
||||
// Merge strategy: prefer database data
|
||||
const merged = new Map<number, Clinic>();
|
||||
|
||||
// Add database clinics
|
||||
dbClinics.forEach(clinic => {
|
||||
merged.set(clinic.id, clinic);
|
||||
});
|
||||
|
||||
// Add local clinics that don't exist in DB
|
||||
localClinics.forEach(localClinic => {
|
||||
if (!merged.has(localClinic.id)) {
|
||||
merged.set(localClinic.id, localClinic);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(merged.values());
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error syncing clinics with database:', error);
|
||||
// Return local clinics as fallback
|
||||
return localClinics;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
fetchAllClinics,
|
||||
fetchClinic,
|
||||
fetchClinicByKode,
|
||||
createClinic,
|
||||
updateClinic,
|
||||
deleteClinic,
|
||||
fetchClinicsForDropdown,
|
||||
syncWithDatabase,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useClinicStore } from '@/stores/clinicStore';
|
||||
|
||||
export const useGrandPaviliun = () => {
|
||||
const queueStore = useQueueStore();
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
// Expose methods for UI
|
||||
const fetchEksekutifData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Pastikan data master ruangan sudah dimuat
|
||||
if (!masterStore.ruangData || masterStore.ruangData.length === 0) {
|
||||
// Assume it's loaded in index or master layouts, but fallback if not
|
||||
}
|
||||
|
||||
const eksekutifClinics = masterStore.ruangData.filter(r => r.jenisLayanan === 'Eksekutif');
|
||||
|
||||
// Ambil data antrean untuk semua klinik eksekutif
|
||||
const promises = eksekutifClinics.map(clinic => {
|
||||
queueStore.registerClinicInterest(clinic.kodeKlinik);
|
||||
return queueStore.fetchPatientsForClinic(clinic.kodeKlinik);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Connect WebSocket if not connected
|
||||
if (!queueStore.isWsConnected) {
|
||||
queueStore.initWebSocket('admin-klinik-ruang-eksekutif');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching eksekutif data:', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Computed data for UI
|
||||
const clinics = computed(() => {
|
||||
const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif');
|
||||
|
||||
return eksekutifClinics.map(clinic => {
|
||||
// Find all patients for this clinic
|
||||
const clinicPatients = queueStore.allPatients.filter(p => p.kodeKlinik === clinic.kodeKlinik && p.processStage === 'klinik-ruang' && p.status !== 'processed');
|
||||
|
||||
// Find current processing patient (we take the first room's current patient if multiple, or global)
|
||||
// Since design shows 1 column per clinic, we find the first patient marked as processing.
|
||||
// Usually, queueStore.currentProcessingPatient is keyed by `klinik-ruang-${kodeKlinik}-${nomorRuang}`
|
||||
let currentPatientObj = null;
|
||||
|
||||
// We look through clinic rooms to find a processing patient
|
||||
for (const ruang of clinic.ruangList) {
|
||||
const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`;
|
||||
const processing = queueStore.currentProcessingPatient[key];
|
||||
if (processing) {
|
||||
currentPatientObj = {
|
||||
...processing,
|
||||
roomIndicator: `RUANG ${ruang.nomorRuang}`,
|
||||
nomorRuang: ruang.nomorRuang
|
||||
};
|
||||
break; // Stop at first one for the column view
|
||||
}
|
||||
}
|
||||
|
||||
// Get queue patients (those not currently processing)
|
||||
const currentPatientNo = currentPatientObj ? currentPatientObj.no : null;
|
||||
const queuePatients = clinicPatients
|
||||
.filter(p => p.no !== currentPatientNo)
|
||||
.map(p => ({
|
||||
...p,
|
||||
isActive: false // Could be based on selection later
|
||||
}));
|
||||
|
||||
return {
|
||||
kodeKlinik: clinic.kodeKlinik,
|
||||
namaKlinik: clinic.namaKlinik,
|
||||
currentPatient: currentPatientObj,
|
||||
queuePatients: queuePatients,
|
||||
roomOptions: clinic.ruangList
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const monitorRooms = computed(() => {
|
||||
const rooms = [];
|
||||
let idCounter = 1;
|
||||
|
||||
const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif');
|
||||
|
||||
eksekutifClinics.forEach(clinic => {
|
||||
clinic.ruangList.forEach(ruang => {
|
||||
const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`;
|
||||
const processing = queueStore.currentProcessingPatient[key];
|
||||
|
||||
// As user specified: Monitoring only shows data when 'Panggil Pemeriksaan' (Tindakan) is true
|
||||
// Assuming calledTindakan flag exists on processing patient
|
||||
const isActiveInMonitoring = processing && processing.calledTindakan;
|
||||
|
||||
rooms.push({
|
||||
id: idCounter++,
|
||||
name: `RUANG ${ruang.nomorRuang}`,
|
||||
activePatient: isActiveInMonitoring ? (processing.noAntrian?.split(" |")[0] || processing.barcode) : null,
|
||||
clinicName: clinic.namaKlinik
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return rooms;
|
||||
});
|
||||
|
||||
// Actions
|
||||
const handlePemeriksaanAwal = (patient, kodeKlinik, nomorRuang) => {
|
||||
if (!patient || !kodeKlinik || !nomorRuang) return;
|
||||
|
||||
const roomInfo = masterStore.ruangData
|
||||
.find(c => c.kodeKlinik === kodeKlinik)
|
||||
?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang));
|
||||
|
||||
if (!roomInfo) return;
|
||||
|
||||
const wsData = {
|
||||
action: 'callPemeriksaanAwal',
|
||||
patientId: patient.barcode || patient.visitId,
|
||||
patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode,
|
||||
patientName: patient.name || '',
|
||||
roomName: roomInfo.namaRuang,
|
||||
clinicCode: kodeKlinik,
|
||||
tipePanggilan: 'Pemeriksaan Awal'
|
||||
};
|
||||
|
||||
queueStore.sendViaPost(wsData);
|
||||
|
||||
// Update local state to mark as called
|
||||
if (patient.no) {
|
||||
const idx = queueStore.allPatients.findIndex(p => p.no === patient.no);
|
||||
if (idx !== -1) {
|
||||
queueStore.allPatients[idx].calledPemeriksaanAwal = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePanggilPemeriksaan = (patient, kodeKlinik, nomorRuang) => {
|
||||
if (!patient || !kodeKlinik || !nomorRuang) return;
|
||||
|
||||
const roomInfo = masterStore.ruangData
|
||||
.find(c => c.kodeKlinik === kodeKlinik)
|
||||
?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang));
|
||||
|
||||
if (!roomInfo) return;
|
||||
|
||||
const wsData = {
|
||||
action: 'callTindakan',
|
||||
patientId: patient.barcode || patient.visitId,
|
||||
patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode,
|
||||
patientName: patient.name || '',
|
||||
roomName: roomInfo.namaRuang,
|
||||
clinicCode: kodeKlinik,
|
||||
tipePanggilan: 'Tindakan'
|
||||
};
|
||||
|
||||
queueStore.sendViaPost(wsData);
|
||||
|
||||
// Update local state to mark as called
|
||||
if (patient.no) {
|
||||
const idx = queueStore.allPatients.findIndex(p => p.no === patient.no);
|
||||
if (idx !== -1) {
|
||||
queueStore.allPatients[idx].calledTindakan = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processPatientAction = async (patient, action, kodeKlinik, nomorRuang) => {
|
||||
if (!patient || !kodeKlinik || !nomorRuang) return { success: false, message: 'Data tidak lengkap' };
|
||||
|
||||
const result = await queueStore.processPatientKlinikRuang(
|
||||
patient,
|
||||
action,
|
||||
kodeKlinik,
|
||||
nomorRuang
|
||||
);
|
||||
|
||||
if (action === 'pending' && result.success) {
|
||||
const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no);
|
||||
if (patientIndex !== -1) {
|
||||
queueStore.allPatients[patientIndex] = {
|
||||
...queueStore.allPatients[patientIndex],
|
||||
status: 'pending'
|
||||
};
|
||||
const key = `klinik-ruang-${kodeKlinik}-${nomorRuang}`;
|
||||
queueStore.currentProcessingPatient[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
clinics,
|
||||
monitorRooms,
|
||||
loading,
|
||||
fetchEksekutifData,
|
||||
handlePemeriksaanAwal,
|
||||
handlePanggilPemeriksaan,
|
||||
processPatientAction
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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: Roles, Groups, and their own Username
|
||||
const entities = [...new Set([
|
||||
(currentUser as any).namaUser, // Individual user mapping support
|
||||
...roles,
|
||||
...groups
|
||||
])].filter(Boolean);
|
||||
|
||||
if (entities.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
|
||||
// Fetch all hak akses data
|
||||
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses', { headers });
|
||||
|
||||
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 headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
|
||||
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses', { headers });
|
||||
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,81 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* Reusable Infinite Scroll Composable
|
||||
* @param {import('vue').Ref<Array>} sourceData - The full source array of data
|
||||
* @param {number} pageSize - Number of items to load per "page"
|
||||
* @param {Object} options - Additional options
|
||||
* @returns {Object} { visibleItems, targetRef, loadMore, reset }
|
||||
*/
|
||||
export function useInfiniteScroll(sourceData, pageSize = 20, options = {}) {
|
||||
const visibleCount = ref(pageSize);
|
||||
const targetRef = ref(null);
|
||||
|
||||
// Compute visible items based on current count
|
||||
const visibleItems = computed(() => {
|
||||
const data = sourceData.value || [];
|
||||
return data.slice(0, visibleCount.value);
|
||||
});
|
||||
|
||||
// Check if there are more items to load
|
||||
const hasMore = computed(() => {
|
||||
const data = sourceData.value || [];
|
||||
return visibleCount.value < data.length;
|
||||
});
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore.value) {
|
||||
visibleCount.value += pageSize;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
visibleCount.value = pageSize;
|
||||
};
|
||||
|
||||
// Setup IntersectionObserver
|
||||
let observer = null;
|
||||
|
||||
onMounted(() => {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting && hasMore.value) {
|
||||
loadMore();
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '100px', // Preload before reaching bottom
|
||||
threshold: 0.1,
|
||||
...options
|
||||
});
|
||||
|
||||
if (targetRef.value) {
|
||||
observer.observe(targetRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) observer.disconnect();
|
||||
});
|
||||
|
||||
// Watch for targetRef changes (e.g., if valid status changes)
|
||||
watch(targetRef, (el) => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
if (el) observer.observe(el);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when source data changes significantly (optional, depending on use case)
|
||||
// watch(sourceData, () => {
|
||||
// reset(); // Uncomment if you want to reset scroll on data refresh
|
||||
// });
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
targetRef,
|
||||
loadMore,
|
||||
reset,
|
||||
hasMore
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,10 @@ export const usePermission = () => {
|
||||
*/
|
||||
const fetchPermission = async (path: string) => {
|
||||
const { group, role } = parsePath(path);
|
||||
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`;
|
||||
const config = useRuntimeConfig();
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const origin = new URL(apiBase).origin;
|
||||
const url = `${origin}/api/permission?roles=${role}&groups=${group}`;
|
||||
|
||||
const { data, error } = await useFetch(url, {
|
||||
method: "GET",
|
||||
|
||||
+186
-36
@@ -1,9 +1,18 @@
|
||||
// composables/useQueue.js
|
||||
import { ref, computed } from "vue";
|
||||
import { useQueueStore } from "../stores/queueStore";
|
||||
import { useLoketStore } from "../stores/loketStore";
|
||||
|
||||
export const useQueue = (adminType = "loket") => {
|
||||
export const useQueue = (adminType = "loket", specificId = null) => {
|
||||
const queueStore = useQueueStore();
|
||||
const loketStore = useLoketStore();
|
||||
|
||||
// Normalize specificId to a reactive value
|
||||
const idValue = computed(() => {
|
||||
if (typeof specificId === 'function') return specificId();
|
||||
if (specificId && typeof specificId === 'object' && 'value' in specificId) return specificId.value;
|
||||
return specificId;
|
||||
});
|
||||
|
||||
// Local state
|
||||
const snackbar = ref(false);
|
||||
@@ -26,9 +35,10 @@ export const useQueue = (adminType = "loket") => {
|
||||
return patients.value;
|
||||
});
|
||||
|
||||
// Computed from store - filtered by stage
|
||||
// Computed from store - isolated by specificId if provided
|
||||
const currentProcessingPatient = computed(() => {
|
||||
return queueStore.currentProcessingPatient[adminType];
|
||||
const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
|
||||
return queueStore.currentProcessingPatient[key];
|
||||
});
|
||||
|
||||
// Derive from stagePatients - ADD DEBUG LOGS
|
||||
@@ -54,10 +64,14 @@ export const useQueue = (adminType = "loket") => {
|
||||
return patients;
|
||||
});
|
||||
|
||||
const waitingPatients = computed(() => stagePatients.value.waiting || []);
|
||||
const anjunganPatients = computed(() => stagePatients.value.anjungan || []);
|
||||
|
||||
// Pasien yang belum dipanggil (status "menunggu")
|
||||
const menungguPatients = computed(() => stagePatients.value.menunggu || []);
|
||||
|
||||
const nextPatient = computed(() => {
|
||||
return waitingPatients.value[0] || null;
|
||||
// Prioritaskan pasien menunggu, baru anjungan
|
||||
return menungguPatients.value[0] || anjunganPatients.value[0] || null;
|
||||
});
|
||||
|
||||
// Total pasien hanya untuk stage admin ini
|
||||
@@ -66,31 +80,99 @@ export const useQueue = (adminType = "loket") => {
|
||||
return total.value;
|
||||
});
|
||||
|
||||
const quotaUsed = computed(() => queueStore.quotaUsed);
|
||||
// Helper to check if a patient is relevant to the current admin view
|
||||
const isPatientRelevant = (p) => {
|
||||
if (p.processStage !== adminType) return false;
|
||||
const targetId = idValue.value;
|
||||
if (!targetId) return true; // No specific ID, show all for this stage
|
||||
|
||||
if (adminType === 'loket') {
|
||||
const thisLoket = loketStore.getLoketById(parseInt(targetId));
|
||||
if (!thisLoket) return String(p.loketId) === String(targetId);
|
||||
|
||||
// Must be assigned to this loket OR unassigned but clinic is served by this loket
|
||||
const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId);
|
||||
if (isAssignedToThis) return true;
|
||||
|
||||
// Add strict isolation between Eksekutif and Reguler for unassigned tickets
|
||||
const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E'));
|
||||
const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000;
|
||||
|
||||
if (isPatientEksekutif !== isLoketEksekutif) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik);
|
||||
|
||||
return isServedByThis;
|
||||
}
|
||||
|
||||
if (adminType === 'klinik') {
|
||||
// Filter by clinic code or clinic name
|
||||
return String(p.kodeKlinik) === String(targetId) || String(p.klinik) === String(targetId);
|
||||
}
|
||||
|
||||
if (adminType === 'penunjang') {
|
||||
// Penunjang usually uses clinic name as the identifier in seeds
|
||||
return String(p.klinik) === String(targetId) || String(p.kodeKlinik) === String(targetId);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 1. Menunggu Count (Backlog ONLY - Strictly status 'menunggu')
|
||||
const menungguCount = computed(() => {
|
||||
let list = menungguPatients.value || [];
|
||||
return list.filter(isPatientRelevant).length;
|
||||
});
|
||||
|
||||
// 2. Calculated Quota Used (Dynamic - any ticket NOT 'menunggu' counts as used)
|
||||
const calculatedQuotaUsed = computed(() => {
|
||||
const list = queueStore.allPatients || [];
|
||||
return list.filter(p => {
|
||||
if (!isPatientRelevant(p)) return false;
|
||||
// Status is NOT 'menunggu' (meaning it's being served or finished)
|
||||
return p.status !== 'menunggu';
|
||||
}).length;
|
||||
});
|
||||
|
||||
const quotaUsed = computed(() => calculatedQuotaUsed.value);
|
||||
|
||||
// Expose dev helper to refresh seed data
|
||||
const resetPatients = () => queueStore.resetPatients();
|
||||
|
||||
// Filtered lists
|
||||
const filteredKliniks = computed(() => {
|
||||
if (!klinikSearch.value) return queueStore.kliniks;
|
||||
return queueStore.kliniks.filter((k) =>
|
||||
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
|
||||
);
|
||||
let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
|
||||
if (klinikSearch.value) {
|
||||
result = result.filter((k) =>
|
||||
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
// Sort alphabetically by name
|
||||
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
});
|
||||
|
||||
const filteredPenunjangs = computed(() => {
|
||||
if (!penunjangSearch.value) return queueStore.penunjangs;
|
||||
return queueStore.penunjangs.filter((p) =>
|
||||
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
|
||||
);
|
||||
let result = queueStore.penunjangs;
|
||||
if (penunjangSearch.value) {
|
||||
result = result.filter((p) =>
|
||||
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
// Sort alphabetically by name
|
||||
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
});
|
||||
|
||||
const filteredChangeKliniks = computed(() => {
|
||||
if (!changeKlinikSearch.value) return queueStore.kliniks;
|
||||
return queueStore.kliniks.filter((k) =>
|
||||
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
|
||||
);
|
||||
let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
|
||||
if (changeKlinikSearch.value) {
|
||||
result = result.filter((k) =>
|
||||
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
// Sort alphabetically by name
|
||||
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
});
|
||||
|
||||
// Methods
|
||||
@@ -100,36 +182,92 @@ export const useQueue = (adminType = "loket") => {
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const callNext = () => {
|
||||
const result = queueStore.callNext(adminType);
|
||||
const callNext = (specificIdOverride = null) => {
|
||||
const targetId = specificIdOverride || idValue.value;
|
||||
const result = queueStore.callNext(adminType, targetId);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const callMultiplePatients = (count) => {
|
||||
const result = queueStore.callMultiplePatients(count, adminType);
|
||||
const callMultiplePatients = (count, specificIdOverride = null) => {
|
||||
const targetId = specificIdOverride || idValue.value;
|
||||
const result = queueStore.callMultiplePatients(count, adminType, targetId);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const processPatient = (patient, action) => {
|
||||
const result = queueStore.processPatient(patient, action, adminType);
|
||||
const processPatient = async (patient, action) => {
|
||||
const result = await queueStore.processPatient(patient, action, adminType, idValue.value);
|
||||
|
||||
let color = "success";
|
||||
if (action === "terlambat") color = "warning";
|
||||
else if (action === "pending") color = "info";
|
||||
|
||||
showSnackbar(result.message, color);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Helper function untuk mendapatkan pasien yang sedang diproses dari store
|
||||
const getCurrentProcessingPatientFromStore = () => {
|
||||
try {
|
||||
const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
|
||||
const processingPatient = queueStore.currentProcessingPatient?.[key];
|
||||
if (!processingPatient) return null;
|
||||
|
||||
// Dapatkan data terbaru dari allPatients langsung (bukan dari getPatientsByStage yang sudah difilter)
|
||||
// allPatients adalah ref yang di-export dari Pinia store, bisa diakses langsung atau dengan .value
|
||||
// Coba akses langsung dulu, jika undefined baru coba dengan .value
|
||||
let allPatients = [];
|
||||
if (queueStore.allPatients) {
|
||||
// Jika allPatients adalah ref (punya .value)
|
||||
allPatients = Array.isArray(queueStore.allPatients)
|
||||
? queueStore.allPatients
|
||||
: (queueStore.allPatients.value || []);
|
||||
}
|
||||
|
||||
// Cari pasien berdasarkan no, barcode, atau noAntrian
|
||||
const latestPatient = allPatients.find(
|
||||
p => (p && p.no === processingPatient.no) ||
|
||||
(p && p.barcode && p.barcode === processingPatient.barcode) ||
|
||||
(p && p.noAntrian && p.noAntrian === processingPatient.noAntrian)
|
||||
);
|
||||
|
||||
// Jika ditemukan, return data terbaru, jika tidak return data dari currentProcessingPatient
|
||||
return latestPatient || processingPatient;
|
||||
} catch (error) {
|
||||
console.error('Error in getCurrentProcessingPatientFromStore:', error);
|
||||
// Fallback: return processingPatient dari store jika ada
|
||||
return queueStore.currentProcessingPatient?.[adminType] || null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectKlinik = (klinik) => {
|
||||
const result = queueStore.createAntreanKlinik(klinik, currentProcessingPatient.value, adminType);
|
||||
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
|
||||
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
|
||||
|
||||
if (!patient) {
|
||||
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
|
||||
showKlinikDialog.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = queueStore.createAntreanKlinik(klinik, patient, adminType);
|
||||
showSnackbar(result.message, "success");
|
||||
showKlinikDialog.value = false;
|
||||
};
|
||||
|
||||
const selectPenunjang = (penunjang) => {
|
||||
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
|
||||
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
|
||||
|
||||
if (!patient) {
|
||||
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
|
||||
showPenunjangDialog.value = false;
|
||||
selectedPatientForPenunjang.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = queueStore.createAntreanPenunjang(
|
||||
penunjang,
|
||||
currentProcessingPatient.value,
|
||||
patient,
|
||||
adminType
|
||||
);
|
||||
showSnackbar(result.message, "success");
|
||||
@@ -143,20 +281,31 @@ export const useQueue = (adminType = "loket") => {
|
||||
};
|
||||
|
||||
const changeKlinik = (klinik) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
const result = queueStore.changeKlinik(
|
||||
currentProcessingPatient.value,
|
||||
klinik,
|
||||
adminType
|
||||
);
|
||||
showSnackbar(result.message, result.success ? "success" : "error");
|
||||
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
|
||||
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
|
||||
|
||||
if (!patient) {
|
||||
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
|
||||
showChangeKlinikDialog.value = false;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const result = queueStore.changeKlinik(
|
||||
patient,
|
||||
klinik,
|
||||
adminType,
|
||||
idValue.value
|
||||
);
|
||||
showSnackbar(result.message, result.success ? "success" : "error");
|
||||
showChangeKlinikDialog.value = false;
|
||||
return result;
|
||||
};
|
||||
|
||||
const processNextQueue = () => {
|
||||
const result = queueStore.processNextQueue(adminType);
|
||||
|
||||
const processNextQueue = async () => {
|
||||
const result = await queueStore.processNextQueue(adminType, idValue.value);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
return result;
|
||||
};
|
||||
|
||||
const getRowClass = (item) => {
|
||||
@@ -184,7 +333,8 @@ export const useQueue = (adminType = "loket") => {
|
||||
diLoketPatients,
|
||||
terlambatPatients,
|
||||
pendingPatients,
|
||||
waitingPatients,
|
||||
anjunganPatients,
|
||||
menungguPatients,
|
||||
nextPatient,
|
||||
totalPasien,
|
||||
quotaUsed,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useRuntimeConfig } from '#app';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export const useQueueAPI = () => {
|
||||
const config = useRuntimeConfig();
|
||||
const verificationApiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const externalApiBase = config.public.externalApiBaseUrl;
|
||||
|
||||
const fetchRawLoketPatients = async (loketId: string | number) => {
|
||||
try {
|
||||
const rawData: any = await $fetch(`${verificationApiBase}/loket/${loketId}`);
|
||||
if (rawData.metadata && rawData.metadata.code !== 200) {
|
||||
throw new Error(rawData.message || 'API returned error status');
|
||||
}
|
||||
return rawData.data || [];
|
||||
} catch (error: any) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRawClinicPatients = async (clinicId: string | number) => {
|
||||
const url = `${externalApiBase}/visit?klinik_id=${clinicId}&limit=500`;
|
||||
try {
|
||||
const rawResponse: any = await $fetch(url);
|
||||
return rawResponse?.data || [];
|
||||
} catch (error: any) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTicketStatus = async (barcode: string, statuspasien: string, statuspasien2: string, idklinikstatus: string, idklinikstatus2: string) => {
|
||||
return await $fetch(`${verificationApiBase}/tiket/update`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
barcode: barcode || "",
|
||||
statuspasien,
|
||||
statuspasien2,
|
||||
idklinikstatus,
|
||||
idklinikstatus2
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const completeTicketStatus = async (idloket: string, barcode: string, statuspasien: string, idklinikstatus: string) => {
|
||||
return await $fetch(`${verificationApiBase}/tiket/selesai`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
idloket: String(idloket || ""),
|
||||
barcode: barcode || "",
|
||||
statuspasien,
|
||||
idklinikstatus
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
fetchRawLoketPatients,
|
||||
fetchRawClinicPatients,
|
||||
updateTicketStatus,
|
||||
completeTicketStatus
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRuntimeConfig } from '#app';
|
||||
import { useWebSocket } from '@/composables/useWebSocket';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export interface QueueSyncDeps {
|
||||
allPatients: Ref<QueuePatient[]>;
|
||||
currentProcessingPatient: Ref<Record<string, QueuePatient>>;
|
||||
activeLoketInterest: Ref<Record<string, number>>;
|
||||
activeClinicInterest: Ref<Record<string, number>>;
|
||||
globalInterestCount: Ref<number>;
|
||||
fetchPatientsForLoket: (id: string | number, force?: boolean) => void;
|
||||
fetchPatientsForClinic: (id: string, force?: boolean) => void;
|
||||
fetchAllPatients: () => void;
|
||||
}
|
||||
|
||||
export const useQueueSync = (deps: QueueSyncDeps) => {
|
||||
const isWsConnected = ref<boolean>(false);
|
||||
const wsClientId = ref<string>(`client-${Math.random().toString(36).substring(7)}`);
|
||||
const lastGlobalCall = ref<any>(null);
|
||||
const lastKlinikCall = ref<any>(null);
|
||||
|
||||
const onWsMessage = (data: any) => {
|
||||
// Robust data extraction: some relays wrap data in another 'data' property
|
||||
let messageData = data?.data || data;
|
||||
if (messageData?.data && !messageData.callKlinikEvent && !messageData.callEvent) {
|
||||
messageData = messageData.data; // Double wrap check
|
||||
}
|
||||
|
||||
const targetLoketId = messageData?.loketId || messageData?.idloket;
|
||||
const targetKlinikId = messageData?.klinikId || messageData?.idklinik;
|
||||
|
||||
// Handle Call Events and WS messages
|
||||
if (messageData?.triggerRefresh) {
|
||||
if (messageData.klinikId) {
|
||||
// console.log(`🔄 [queueSync] Received refresh trigger for clinic ${messageData.klinikId}`);
|
||||
|
||||
// Handle current processing update if provided
|
||||
if (messageData.currentProcessingUpdate) {
|
||||
// console.log(`🎯 [queueSync] Applying current processing update:`, messageData.currentProcessingUpdate);
|
||||
|
||||
Object.keys(messageData.currentProcessingUpdate).forEach(key => {
|
||||
deps.currentProcessingPatient.value[key] = messageData.currentProcessingUpdate[key];
|
||||
|
||||
// Also patch the status in allPatients if possible
|
||||
const processingPatient = messageData.currentProcessingUpdate[key];
|
||||
if (processingPatient && processingPatient.no) {
|
||||
const idx = deps.allPatients.value.findIndex(p => p.no === processingPatient.no);
|
||||
if (idx !== -1) {
|
||||
deps.allPatients.value[idx] = { ...deps.allPatients.value[idx], status: 'di-loket' };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deps.fetchPatientsForClinic(messageData.klinikId, true);
|
||||
}
|
||||
}
|
||||
if (messageData?.callEvent) {
|
||||
lastGlobalCall.value = messageData.callEvent;
|
||||
}
|
||||
|
||||
// Handle Klinik Call Events (cross-device sync for AntrianKlinikRuang display)
|
||||
if (messageData?.callKlinikEvent) {
|
||||
const ev = messageData.callKlinikEvent;
|
||||
// console.log('🏥 [queueSync] Klinik call event received:', ev);
|
||||
|
||||
// PERSISTENCE FIX: Save to lastKlinikCall for displays to watch
|
||||
lastKlinikCall.value = ev;
|
||||
|
||||
// Find the patient in allPatients and patch directly for immediate UI update
|
||||
const idx = deps.allPatients.value.findIndex(p =>
|
||||
p.processStage === 'klinik-ruang' &&
|
||||
p.kodeKlinik === ev.kodeKlinik &&
|
||||
(
|
||||
(p.barcode && String(p.barcode) === String(ev.barcode)) ||
|
||||
(p.noAntrian && p.noAntrian.split(' |')[0] === ev.noantrian)
|
||||
)
|
||||
);
|
||||
|
||||
if (idx !== -1) {
|
||||
const updatedPatient = {
|
||||
...deps.allPatients.value[idx],
|
||||
tipeLayanan: ev.tipeLayanan,
|
||||
lastCalledAt: ev.lastCalledAt || new Date().toISOString(),
|
||||
lastCalledTipeLayanan: ev.tipeLayanan,
|
||||
status: 'di-loket' as any,
|
||||
calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : deps.allPatients.value[idx].calledPemeriksaanAwal,
|
||||
calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : deps.allPatients.value[idx].calledTindakan
|
||||
};
|
||||
|
||||
deps.allPatients.value[idx] = updatedPatient;
|
||||
// console.log(`✅ [queueSync] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`);
|
||||
} else {
|
||||
console.warn(`⚠️ [queueSync] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`);
|
||||
}
|
||||
}
|
||||
// TRIGGER STRATEGIC REFRESHES
|
||||
let refreshedSomething = false;
|
||||
|
||||
if (targetLoketId) {
|
||||
deps.fetchPatientsForLoket(targetLoketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
if (targetKlinikId) {
|
||||
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
|
||||
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
|
||||
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
|
||||
deps.fetchPatientsForClinic(clinicToFetch, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.globalInterestCount.value > 0) {
|
||||
deps.fetchAllPatients();
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
// ALWAYS refresh our own active interests when a WebSocket message is received
|
||||
const interestingLokets = Object.keys(deps.activeLoketInterest.value);
|
||||
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
|
||||
|
||||
if (interestingLokets.length > 0) {
|
||||
interestingLokets.forEach(loketId => {
|
||||
if (String(loketId) !== String(targetLoketId)) {
|
||||
deps.fetchPatientsForLoket(loketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (interestingClinics.length > 0) {
|
||||
interestingClinics.forEach(kodeKlinik => {
|
||||
if (String(kodeKlinik) !== String(targetKlinikId)) {
|
||||
deps.fetchPatientsForClinic(kodeKlinik, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!refreshedSomething) {
|
||||
// console.log(`🔕 [queueSync] WS trigger received but no active interest matched. Skipping.`);
|
||||
}
|
||||
};
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const wsBaseUrl = config.public?.wsBaseUrl || "ws://10.10.123.135:8084/api/v1/ws";
|
||||
|
||||
const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: wsClientId,
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
reconnectInterval: 2000,
|
||||
maxReconnectAttempts: 9999,
|
||||
onOpen: () => {
|
||||
// console.log('✅ [queueSync] WebSocket connected');
|
||||
isWsConnected.value = true;
|
||||
},
|
||||
onClose: () => {
|
||||
// console.log('❌ [queueSync] WebSocket disconnected');
|
||||
isWsConnected.value = false;
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('⚠️ [queueSync] WebSocket error:', err);
|
||||
isWsConnected.value = false;
|
||||
},
|
||||
onMessage: onWsMessage
|
||||
});
|
||||
|
||||
let _autoSyncInterval: any = null;
|
||||
|
||||
const startAutoSync = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (_autoSyncInterval) return;
|
||||
|
||||
// console.log('🔄 [queueSync] Starting store-level auto-sync (30s interval)');
|
||||
|
||||
_autoSyncInterval = setInterval(async () => {
|
||||
const hasLoketInterest = Object.keys(deps.activeLoketInterest.value).length > 0;
|
||||
const hasClinicInterest = Object.keys(deps.activeClinicInterest.value).length > 0;
|
||||
const hasGlobalInterest = deps.globalInterestCount.value > 0;
|
||||
|
||||
if (hasGlobalInterest) {
|
||||
deps.fetchAllPatients();
|
||||
} else {
|
||||
if (hasLoketInterest) {
|
||||
Object.keys(deps.activeLoketInterest.value).forEach(loketId => {
|
||||
deps.fetchPatientsForLoket(loketId, true);
|
||||
});
|
||||
}
|
||||
if (hasClinicInterest) {
|
||||
Object.keys(deps.activeClinicInterest.value).forEach(kodeKlinik => {
|
||||
deps.fetchPatientsForClinic(kodeKlinik, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
const stopAutoSync = () => {
|
||||
if (_autoSyncInterval) {
|
||||
clearInterval(_autoSyncInterval);
|
||||
_autoSyncInterval = null;
|
||||
// console.log('⏹️ [queueSync] Store-level auto-sync stopped');
|
||||
}
|
||||
};
|
||||
|
||||
const initWebSocket = (customClientId: string | null = null) => {
|
||||
if (isConnected.value && customClientId === wsClientId.value) {
|
||||
// console.log('🔌 [queueSync] WebSocket already connected with same ID.');
|
||||
startAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customClientId) {
|
||||
wsClientId.value = customClientId;
|
||||
disconnect();
|
||||
}
|
||||
|
||||
// console.log(`🔌 [queueSync] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
|
||||
connect();
|
||||
startAutoSync();
|
||||
};
|
||||
|
||||
return {
|
||||
isWsConnected,
|
||||
wsClientId,
|
||||
lastGlobalCall,
|
||||
lastKlinikCall,
|
||||
initWebSocket,
|
||||
disconnectWebSocket: disconnect,
|
||||
sendViaPost,
|
||||
startAutoSync,
|
||||
stopAutoSync
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,636 @@
|
||||
import { ref } from 'vue';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export interface ThermalPrintData {
|
||||
noAntrian: string;
|
||||
barcode: string;
|
||||
klinik: string;
|
||||
ruang?: string;
|
||||
nomorRuang?: string;
|
||||
shift: string;
|
||||
pembayaran: string;
|
||||
tanggal: string;
|
||||
waktu: string;
|
||||
namaDokter?: string;
|
||||
status?: 'ALLOWED' | 'NOT_ALLOWED';
|
||||
}
|
||||
|
||||
export const useThermalPrint = () => {
|
||||
const isPrinting = ref(false);
|
||||
|
||||
// NOTE: generateBarcode telah dihapus dari useThermalPrint
|
||||
// Gunakan generateBarcode dari queueStore.js untuk konsistensi
|
||||
// Semua barcode generation harus melalui queueStore.generateBarcode()
|
||||
|
||||
/**
|
||||
* Generate QR Code data URL
|
||||
*/
|
||||
const generateQRCode = async (data: string): Promise<string> => {
|
||||
try {
|
||||
const qrDataUrl = await QRCode.toDataURL(data, {
|
||||
errorCorrectionLevel: 'M', // Medium error correction (better for standard cameras)
|
||||
type: 'image/png',
|
||||
quality: 1,
|
||||
margin: 2, // Added margin (quiet zone) for better scanner detection
|
||||
width: 400, // High quality width for clear printing
|
||||
version: 3,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
});
|
||||
return qrDataUrl;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR Code:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date untuk tampilan
|
||||
* Format: senin, 12 Jan 2026
|
||||
*/
|
||||
const formatDate = (dateString: string): string => {
|
||||
try {
|
||||
let date = dateString ? new Date(dateString) : new Date();
|
||||
if (isNaN(date.getTime())) {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
// Array nama hari dalam bahasa Indonesia (lowercase)
|
||||
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
||||
// Array nama bulan singkat (title case)
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||
|
||||
const dayName = days[date.getDay()];
|
||||
const day = date.getDate();
|
||||
const month = months[date.getMonth()];
|
||||
const year = date.getFullYear(); // Tahun 4 digit
|
||||
|
||||
return `${dayName}, ${day} ${month} ${year}`;
|
||||
} catch (error) {
|
||||
// Fallback jika error
|
||||
const date = new Date();
|
||||
const days = ['minggu', 'senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||
const dayName = days[date.getDay()];
|
||||
const day = date.getDate();
|
||||
const month = months[date.getMonth()];
|
||||
const year = date.getFullYear();
|
||||
return `${dayName}, ${day} ${month} ${year}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format time untuk tampilan
|
||||
*/
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
let date = dateString ? new Date(dateString) : new Date();
|
||||
if (isNaN(date.getTime())) {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${hours}:${minutes}`;
|
||||
} catch (error) {
|
||||
const date = new Date();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate HTML untuk thermal print
|
||||
*/
|
||||
const generatePrintHTML = async (data: ThermalPrintData): Promise<string> => {
|
||||
// QR Code data: hanya barcode saja (status akan dicek real-time saat check-in dari queueStore)
|
||||
// Format: BARCODE (contoh: 250811100163)
|
||||
// Alasan: Status bisa berubah setelah tiket di-print (dari 'menunggu' jadi 'waiting' setelah dipanggil)
|
||||
// Status akan dicek langsung dari queueStore saat scan QR untuk akurasi real-time
|
||||
|
||||
// Normalize barcode - pastikan tidak ada whitespace dan valid
|
||||
const qrData = String(data.barcode || '').trim();
|
||||
|
||||
if (!qrData) {
|
||||
throw new Error('Barcode tidak valid untuk QR code');
|
||||
}
|
||||
|
||||
console.log('📱 Generating QR code for ticket with barcode:', qrData);
|
||||
const qrCodeImage = await generateQRCode(qrData);
|
||||
console.log('✅ QR code generated successfully');
|
||||
|
||||
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
|
||||
const noAntrianDisplay = data.noAntrian.split(' |')[0];
|
||||
|
||||
// Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi"
|
||||
let ruangInfo = '';
|
||||
|
||||
// 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis)
|
||||
if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) {
|
||||
ruangInfo = `${data.klinik} - ${data.ruang}`;
|
||||
}
|
||||
// 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll)
|
||||
else if (data.klinik && data.nomorRuang) {
|
||||
const ruangNumber = parseInt(data.nomorRuang) || 1;
|
||||
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A'
|
||||
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
|
||||
}
|
||||
// 3. Fallback: gunakan data.ruang yang ada
|
||||
else if (data.klinik && data.ruang) {
|
||||
ruangInfo = `${data.klinik} - ${data.ruang}`;
|
||||
}
|
||||
// 4. Default: hanya nama klinik
|
||||
else if (data.klinik) {
|
||||
ruangInfo = data.klinik;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Nomor Antrian - ${noAntrianDisplay}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 80mm auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 80mm;
|
||||
max-width: 80mm;
|
||||
margin: 0;
|
||||
padding: 2mm 4mm 6mm 4mm;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 1.0;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Feed lines untuk auto cutter (space setelah footer) */
|
||||
.cut-section {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
height: 0;
|
||||
display: block;
|
||||
page-break-after: always;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2mm 4mm 6mm 4mm;
|
||||
}
|
||||
|
||||
.ticket-table {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ticket-content {
|
||||
padding: 2mm 4mm 4mm 4mm;
|
||||
}
|
||||
|
||||
/* Feed setelah print untuk auto cutter */
|
||||
.ticket-table::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 10mm;
|
||||
min-height: 10mm;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: 80mm auto;
|
||||
margin: 2mm 0 6mm 0;
|
||||
/* Margin top kecil, margin bottom besar untuk feed auto cutter */
|
||||
}
|
||||
|
||||
/* Feed tambahan di akhir untuk memastikan kertas cukup untuk dipotong */
|
||||
.cut-section {
|
||||
height: 0 !important;
|
||||
min-height: 0 !important;
|
||||
display: block !important;
|
||||
page-break-after: always !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Pastikan tidak ada page break di tengah content */
|
||||
.ticket-content > * {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
${ruangInfo ? `<div class="klinik-name">${ruangInfo}</div>` : `<div class="klinik-name">${data.klinik}</div>`}
|
||||
|
||||
<div class="qr-code-section">
|
||||
<div class="qr-code">
|
||||
<img src="${qrCodeImage}" alt="QR Code" />
|
||||
</div>
|
||||
<div class="barcode-number">${data.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">${data.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">${data.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">${data.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">${data.pembayaran}</span>
|
||||
</div>
|
||||
|
||||
${data.namaDokter ? `
|
||||
<div 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">${data.namaDokter}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed lines untuk auto cutter (space setelah footer untuk cutter) -->
|
||||
<div class="cut-section"></div>
|
||||
|
||||
<!-- ESC/POS Auto Cut Command Comment (untuk printer driver yang support) -->
|
||||
<!-- ESC/POS Commands for EPSON M352A: -->
|
||||
<!-- ESC i = Full Cut (0x1B 0x69) -->
|
||||
<!-- ESC m = Partial Cut (0x1B 0x6D) -->
|
||||
<!-- GS V 0 = Full Cut (0x1D 0x56 0x00) -->
|
||||
<!-- GS V 1 = Partial Cut (0x1D 0x56 0x01) -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Print thermal ticket
|
||||
*/
|
||||
const printTicket = async (data: ThermalPrintData): Promise<boolean> => {
|
||||
try {
|
||||
isPrinting.value = true;
|
||||
|
||||
// Generate HTML
|
||||
const html = await generatePrintHTML(data);
|
||||
|
||||
// Create print window
|
||||
const printWindow = window.open('', '_blank', 'width=300,height=400');
|
||||
|
||||
if (!printWindow) {
|
||||
throw new Error('Tidak dapat membuka window print. Pastikan pop-up diizinkan.');
|
||||
}
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
|
||||
// Wait for images to load
|
||||
await new Promise((resolve) => {
|
||||
printWindow.onload = () => {
|
||||
setTimeout(() => {
|
||||
printWindow.focus();
|
||||
|
||||
// Add event listener untuk afterprint (auto cut trigger)
|
||||
const handleAfterPrint = () => {
|
||||
console.log('Print completed. Auto cutter should activate.');
|
||||
console.log('Note: For EPSON M352A, ensure printer settings have auto cut enabled.');
|
||||
console.log('ESC/POS Cut Command: ESC i (0x1B 0x69) for full cut');
|
||||
};
|
||||
|
||||
printWindow.addEventListener('afterprint', handleAfterPrint);
|
||||
|
||||
// Print ke printer default
|
||||
// Catatan: Window.print() akan menggunakan printer default yang sudah di-set di sistem
|
||||
// Jika user ingin mengubah printer, mereka perlu mengubah default printer di Windows/OS Settings terlebih dahulu
|
||||
printWindow.print();
|
||||
|
||||
// Note: Untuk printer thermal EPSON M352A, auto cutter akan otomatis aktif jika:
|
||||
// 1. Driver printer sudah dikonfigurasi untuk auto cut
|
||||
// 2. Printer settings di Windows/OS sudah enable auto cut
|
||||
// 3. CSS feed lines di atas sudah cukup untuk trigger auto cut
|
||||
//
|
||||
// ESC/POS Commands yang didukung:
|
||||
// - ESC i (0x1B 0x69): Full Cut
|
||||
// - ESC m (0x1B 0x6D): Partial Cut
|
||||
// - GS V 0 (0x1D 0x56 0x00): Full Cut
|
||||
// - GS V 1 (0x1D 0x56 0x01): Partial Cut
|
||||
|
||||
// Close window after print (optional)
|
||||
// Delay lebih lama untuk memastikan print dan cut selesai
|
||||
setTimeout(() => {
|
||||
printWindow.removeEventListener('afterprint', handleAfterPrint);
|
||||
printWindow.close();
|
||||
}, 1500);
|
||||
|
||||
resolve(true);
|
||||
}, 500);
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error printing ticket:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isPrinting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Print ticket dengan data dari queueStore patient object
|
||||
*/
|
||||
const printTicketFromPatient = async (patient: any): Promise<boolean> => {
|
||||
// Determine tanggal kunjungan
|
||||
let tanggalKunjungan: string;
|
||||
if (patient.visitDate) {
|
||||
// Jika ada visitDate (untuk jadwal lain), gunakan itu
|
||||
tanggalKunjungan = formatDate(patient.visitDate);
|
||||
} else if (patient.visitType === 'SEKARANG' || !patient.visitType) {
|
||||
// Jika kunjungan hari ini
|
||||
tanggalKunjungan = formatDate(new Date().toISOString());
|
||||
} else {
|
||||
// Fallback ke tanggal sekarang
|
||||
tanggalKunjungan = formatDate(new Date().toISOString());
|
||||
}
|
||||
|
||||
// Determine waktu
|
||||
let waktu: string;
|
||||
if (patient.createdAt) {
|
||||
waktu = formatTime(patient.createdAt);
|
||||
} else if (patient.jamPanggil) {
|
||||
// Gunakan jamPanggil jika ada
|
||||
waktu = patient.jamPanggil;
|
||||
} else {
|
||||
waktu = formatTime(new Date().toISOString());
|
||||
}
|
||||
|
||||
// Status tidak lagi disertakan dalam QR code
|
||||
// Status akan dicek real-time dari queueStore saat scan QR
|
||||
// Format QR code: hanya BARCODE saja untuk fleksibilitas status yang bisa berubah
|
||||
|
||||
// Normalize barcode - pastikan format konsisten (trim whitespace)
|
||||
const barcode = String(patient.barcode || '').trim();
|
||||
|
||||
if (!barcode) {
|
||||
console.error('❌ Barcode pasien tidak valid untuk print ticket:', patient);
|
||||
throw new Error('Barcode pasien tidak valid');
|
||||
}
|
||||
|
||||
console.log('🖨️ Printing ticket for patient:', {
|
||||
noAntrian: patient.noAntrian,
|
||||
barcode: barcode,
|
||||
status: patient.status,
|
||||
klinik: patient.klinik
|
||||
});
|
||||
|
||||
const printData: ThermalPrintData = {
|
||||
noAntrian: patient.noAntrian || '',
|
||||
barcode: barcode,
|
||||
klinik: patient.klinik || '',
|
||||
ruang: patient.ruang || undefined,
|
||||
nomorRuang: patient.nomorRuang || undefined,
|
||||
shift: patient.shift || 'Shift 1',
|
||||
pembayaran: patient.pembayaran || '',
|
||||
tanggal: tanggalKunjungan,
|
||||
waktu: waktu,
|
||||
namaDokter: patient.namaDokter || undefined,
|
||||
// Status tidak digunakan lagi, hanya untuk kompatibilitas interface
|
||||
status: undefined
|
||||
};
|
||||
|
||||
return await printTicket(printData);
|
||||
};
|
||||
|
||||
return {
|
||||
isPrinting,
|
||||
printTicket,
|
||||
printTicketFromPatient,
|
||||
generateQRCode
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// composables/useVisitAPI.ts
|
||||
|
||||
export interface VisitStats {
|
||||
today_patients: number;
|
||||
today_active_queues_by_service: Record<string, number>;
|
||||
average_waiting_seconds: number;
|
||||
monthly_trend: Array<{
|
||||
count: number;
|
||||
month: string;
|
||||
payment_type: string;
|
||||
}>;
|
||||
total_by_payment_type: Record<string, number>;
|
||||
payment_type_distribution_percent: Record<string, number>;
|
||||
average_waiting_by_service_seconds: Record<string, number>;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const useVisitAPI = () => {
|
||||
const config = useRuntimeConfig();
|
||||
// We use the configured external API URL directly
|
||||
const statsURL = `${config.public.externalApiBaseUrl}/visit/stats`;
|
||||
|
||||
/**
|
||||
* Fetch visit statistics
|
||||
*/
|
||||
const fetchStats = async (filters: { from?: string; to?: string; service_code?: string; payment_type?: string } = {}): Promise<VisitStats | null> => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters.from) queryParams.append('from', filters.from);
|
||||
if (filters.to) queryParams.append('to', filters.to);
|
||||
if (filters.service_code) queryParams.append('service_code', filters.service_code);
|
||||
if (filters.payment_type) queryParams.append('payment_type', filters.payment_type);
|
||||
|
||||
const url = `${statsURL}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
const response = await $fetch<{ message: string; data: VisitStats }>(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching visit stats:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
fetchStats,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
// composables/useWebSocket.ts
|
||||
import { ref, computed, onUnmounted, getCurrentInstance, toValue } from 'vue'
|
||||
|
||||
export interface WebSocketConfig {
|
||||
url: string | any
|
||||
clientId: string | any
|
||||
onMessage?: (data: any) => void
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
onError?: (error: Event) => void
|
||||
reconnectInterval?: number
|
||||
maxReconnectAttempts?: number
|
||||
fallbackPostUrl?: string // Optional: use a specific URL for the POST fallback (e.g., a proxy)
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
to_client: string
|
||||
data: any
|
||||
}
|
||||
|
||||
export const useWebSocket = (config: WebSocketConfig) => {
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const isConnected = ref(false)
|
||||
const reconnectAttempts = ref(0)
|
||||
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
const currentUrl = computed(() => toValue(config.url))
|
||||
const currentClientId = computed(() => toValue(config.clientId))
|
||||
|
||||
const wsUrl = computed(() => {
|
||||
let baseUrl = currentUrl.value
|
||||
|
||||
// Automatically upgrade to secure protocols if caller uses HTTPS
|
||||
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
|
||||
if (baseUrl.startsWith('ws://')) {
|
||||
baseUrl = baseUrl.replace('ws://', 'wss://')
|
||||
} else if (baseUrl.startsWith('http://')) {
|
||||
baseUrl = baseUrl.replace('http://', 'https://')
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('client_id', currentClientId.value)
|
||||
return url.toString()
|
||||
})
|
||||
|
||||
const clearHandlers = (wsInstance: WebSocket | null) => {
|
||||
if (wsInstance) {
|
||||
wsInstance.onopen = null
|
||||
wsInstance.onmessage = null
|
||||
wsInstance.onclose = null
|
||||
wsInstance.onerror = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
// Clear any pending reconnect timer first
|
||||
if (reconnectTimer.value) {
|
||||
clearTimeout(reconnectTimer.value)
|
||||
reconnectTimer.value = null
|
||||
}
|
||||
|
||||
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
|
||||
if (ws.value) {
|
||||
if (ws.value.readyState !== WebSocket.CLOSED) {
|
||||
// console.log('🔌 Closing existing WebSocket before new connection...')
|
||||
clearHandlers(ws.value)
|
||||
ws.value.close()
|
||||
}
|
||||
ws.value = null
|
||||
}
|
||||
|
||||
const connectionUrl = wsUrl.value
|
||||
ws.value = new WebSocket(connectionUrl)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
// console.log('✅ WebSocket connected:', currentClientId.value)
|
||||
isConnected.value = true
|
||||
reconnectAttempts.value = 0
|
||||
config.onOpen?.()
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
config.onMessage?.(data)
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
// console.log('❌ WebSocket closed:', currentClientId.value)
|
||||
isConnected.value = false
|
||||
config.onClose?.()
|
||||
|
||||
// Attempt to reconnect only if we didn't just reach max attempts
|
||||
// The reconnectTimer is cleared in disconnect() and at start of connect()
|
||||
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
|
||||
reconnectAttempts.value++
|
||||
const interval = config.reconnectInterval || 3000
|
||||
// console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
|
||||
reconnectTimer.value = setTimeout(() => {
|
||||
connect()
|
||||
}, interval)
|
||||
} else {
|
||||
console.error('Max reconnection attempts reached')
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('⚠️ WebSocket error:', error)
|
||||
config.onError?.(error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating WebSocket:', error)
|
||||
config.onError?.(error as Event)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (reconnectTimer.value) {
|
||||
clearTimeout(reconnectTimer.value)
|
||||
reconnectTimer.value = null
|
||||
}
|
||||
if (ws.value) {
|
||||
// console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
|
||||
clearHandlers(ws.value)
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
}
|
||||
isConnected.value = false
|
||||
reconnectAttempts.value = 0
|
||||
}
|
||||
|
||||
const sendMessage = (message: any) => {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify(message))
|
||||
return true
|
||||
}
|
||||
console.warn('WebSocket is not connected')
|
||||
return false
|
||||
}
|
||||
|
||||
// Send data via POST to WebSocket server
|
||||
const sendViaPost = async (message: WebSocketMessage) => {
|
||||
try {
|
||||
let postUrl = ''
|
||||
|
||||
if (config.fallbackPostUrl) {
|
||||
// Use the explicitly provided fallback URL (likely a relative proxy path)
|
||||
postUrl = config.fallbackPostUrl
|
||||
} else {
|
||||
// Derive post URL from WebSocket URL (legacy behavior)
|
||||
let baseUrl = currentUrl.value
|
||||
if (baseUrl.startsWith('ws://')) {
|
||||
baseUrl = baseUrl.replace('ws://', 'http://')
|
||||
} else if (baseUrl.startsWith('wss://')) {
|
||||
baseUrl = baseUrl.replace('wss://', 'https://')
|
||||
}
|
||||
|
||||
const urlObj = new URL(baseUrl)
|
||||
postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
|
||||
}
|
||||
|
||||
const response = await fetch(postUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error')
|
||||
console.error('❌ POST Error response:', errorText)
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({ success: true }))
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending message via POST:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ws: computed(() => ws.value),
|
||||
isConnected: computed(() => isConnected.value),
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
sendViaPost,
|
||||
reconnectAttempts: computed(() => reconnectAttempts.value),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Default avatar image path
|
||||
// Simpan gambar avatar default di: assets/images/default-avatar.png
|
||||
import defaultAvatarImage from '@/assets/images/default-avatar.png'
|
||||
|
||||
export const DEFAULT_AVATAR = defaultAvatarImage
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: "http://10.10.150.175:3000",
|
||||
supportFile: false,
|
||||
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
|
||||
},
|
||||
|
||||
component: {
|
||||
devServer: {
|
||||
framework: "vue",
|
||||
bundler: "vite",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
describe('Login Page', () => {
|
||||
it('should load the login page', () => {
|
||||
cy.visit('/')
|
||||
// Adjust this to match a selector on your home or login page
|
||||
cy.contains('h1', /login/i).should('be.visible')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "[email protected]",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Components App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div data-cy-root></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
// ***********************************************************
|
||||
// This example support/component.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
import { mount } from 'cypress/vue'
|
||||
|
||||
// Augment the Cypress namespace to include type definitions for
|
||||
// your custom command.
|
||||
// Alternatively, can be defined in cypress/support/component.d.ts
|
||||
// with a <reference path="./component" /> at the top of your spec.
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
mount: typeof mount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add('mount', mount)
|
||||
|
||||
// Example use:
|
||||
// cy.mount(MyComponent)
|
||||
@@ -0,0 +1 @@
|
||||
{"message":"Data visit berhasil diambil","data":[{"id":446,"visit_code":26021300007,"registration_datetime":"2026-02-13T03:40:42Z","active":true,"healthcare_services":[{"id":528,"ticket":"RN007","check_in":true,"check_in_datetime":"2026-02-13T03:41:04Z","datetime_start":"2026-02-13T03:40:42Z","healtcare_service_code":"RN","active":true,"fk_ms_healthcare_service_id":11,"healthcare_service_name":"ANAK","shift_number":1,"available_start_time":"07:00:00","fk_ref_payment_type_id":2,"payment_type_name":"JKN","fk_ms_registration_counter_id":14,"registration_counter_name":"LOKET 14 REG","visit_statuses":[{"id":2174,"fk_ref_visit_status_id":3,"datetime":"2026-02-13T03:40:49.674718Z","visit_status_name":"PG ANJUNGAN","visit_status_description":"Panggilan loket ke Anjungan"},{"id":2182,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.423472Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2177,"fk_ref_visit_status_id":6,"datetime":"2026-02-13T03:41:04.275703Z","visit_status_name":"RT PENDAFTARAN","visit_status_description":"Tunggu Panggilan Loket"},{"id":2175,"fk_ref_visit_status_id":4,"datetime":"2026-02-13T03:40:49.676542Z","visit_status_name":"RT CHECK-IN","visit_status_description":"Tunggu Pasien Check-In"},{"id":2173,"fk_ref_visit_status_id":2,"datetime":"2026-02-13T03:40:42.946501Z","visit_status_name":"RT ANJUNGAN","visit_status_description":"Ruang Tunggu Anjungan"},{"id":2172,"fk_ref_visit_status_id":1,"datetime":"2026-02-13T03:40:42.944133Z","visit_status_name":"CT ANJUNGAN","visit_status_description":"Cetak Tiket Antrian"},{"id":2184,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:20.080008Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"},{"id":2183,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.425723Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2176,"fk_ref_visit_status_id":5,"datetime":"2026-02-13T03:41:04.274554Z","visit_status_name":"PS CHECK-IN","visit_status_description":"Pasien Sudah Check-In"},{"id":2181,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:14.538472Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"}]}]}],"meta":{"limit":10,"offset":0,"total":2139,"total_pages":214,"current_page":1,"has_next":true,"has_prev":false}}
|
||||
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
web-antrean:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: web-antrean
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- NUXT_AUTH_SECRET=${NUXT_AUTH_SECRET}
|
||||
- NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
|
||||
- NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
|
||||
- NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER}
|
||||
- NUXT_KEYCLOAK_LOGOUT_URI=${KEYCLOAK_LOGOUT_URI}
|
||||
- NUXT_POST_LOGOUT_REDIRECT_URI=${POST_LOGOUT_REDIRECT_URI}
|
||||
- NUXT_PUBLIC_AUTH_URL=${AUTH_ORIGIN}
|
||||
- NUXT_EXTERNAL_API_BASE_URL=${EXTERNAL_API_BASE_URL}
|
||||
- NUXT_PUBLIC_VERIFICATION_API_BASE_URL=${VERIFICATION_API_BASE_URL}
|
||||
- NUXT_PUBLIC_ANTRIAN_API_URL=${ANTRIAN_API_URL}
|
||||
- NUXT_PUBLIC_VISIT_API_URL=${VISIT_API_URL}
|
||||
- NUXT_PUBLIC_WS_API_URL=${WS_API_URL}
|
||||
- NUXT_SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS}
|
||||
- NUXT_OAUTH_STATE_DURATION_MINUTES=${OAUTH_STATE_DURATION_MINUTES}
|
||||
- NUXT_PROXY_CLIENT_ORIGIN=${PROXY_CLIENT_ORIGIN}
|
||||
- NUXT_PROXY_TARGET_HOST_FALLBACK=${PROXY_TARGET_HOST_FALLBACK}
|
||||
- NUXT_PROXY_TARGET_HOST_KLINIK_FALLBACK=${PROXY_TARGET_HOST_KLINIK_FALLBACK}
|
||||
volumes:
|
||||
# Mount local data directory to persist the sqlite users database
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- antrean-network
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode < 500 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
antrean-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
data:
|
||||
File diff suppressed because it is too large
Load diff
+741
@@ -0,0 +1,741 @@
|
||||
# 📓 DEVLOG — Development Log
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu mengisi DEVLOG harian:
|
||||
|
||||
```
|
||||
Kamu adalah technical writer. Bantu aku menulis DEVLOG untuk hari ini.
|
||||
|
||||
Context:
|
||||
- Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
- Stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
|
||||
- Yang dikerjakan hari ini: [ceritakan bebas]
|
||||
- Masalah yang ditemui: [ceritakan]
|
||||
- Solusi yang diterapkan: [ceritakan]
|
||||
|
||||
Format output:
|
||||
## [Tanggal] — [Judul Singkat]
|
||||
**Yang dikerjakan:** ...
|
||||
**Keputusan teknis:** ...
|
||||
**Masalah & solusi:** ...
|
||||
**Besok:** ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Entry
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] — [Judul Singkat Pekerjaan]
|
||||
|
||||
**Sprint/Phase:** [nama sprint atau fase]
|
||||
**Durasi:** [X jam]
|
||||
**Status:** ✅ Done | 🔄 In Progress | ⏸ Blocked
|
||||
|
||||
### Yang Dikerjakan
|
||||
- ...
|
||||
|
||||
### Keputusan Teknis
|
||||
> Jelaskan keputusan arsitektur/implementasi penting dan alasannya
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| | | |
|
||||
|
||||
### Besok
|
||||
- [ ] ...
|
||||
|
||||
### Referensi
|
||||
- [link]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log Entries
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
## 2026-07-10 — Migrasi API Endpoint & Project Knowledge Base
|
||||
|
||||
**Sprint/Phase:** Phase 7 — Infrastruktur & Dokumentasi
|
||||
**Durasi:** ~3 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Migrasi endpoint API Antrian dari `10.10.123.140:8089` ke `10.10.150.131:8089` di `.env`
|
||||
- `VERIFICATION_API_BASE_URL`
|
||||
- `ANTRIAN_API_URL`
|
||||
- `PROXY_TARGET_HOST_KLINIK_FALLBACK`
|
||||
- Pembuatan **Project Knowledge Base** (`AGENTS.md`) di `.agents/` untuk agent AI context
|
||||
- Dokumentasi lengkap arsitektur, store hierarchy, API endpoints, auth flow, gotchas
|
||||
- File ini otomatis dimuat sebagai context di setiap session
|
||||
- Update DEVLOG, DEVPLAN, dan PRD berdasarkan kondisi project terkini
|
||||
|
||||
### Keputusan Teknis
|
||||
> **API endpoint migration**: Backend Antrian API dipindah ke server baru (`10.10.150.131`). Semua konfigurasi ada di `.env` — perubahan `.env` membutuhkan restart dev server karena Nuxt membaca env hanya saat startup. Masih ada technical debt berupa hardcoded fallback IP lama di ~25 file source code.
|
||||
|
||||
> **Project Knowledge Base**: Dibuat `.agents/AGENTS.md` sebagai single knowledge document yang mencakup arsitektur, store hierarchy, API config, auth flow, dan known gotchas. Ini mempercepat onboarding dan mengurangi kesalahan saat AI agent bekerja di codebase.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| API masih connect ke IP lama setelah ubah `.env` | Restart dev server (Ctrl+C → `npm run dev`). Nuxt hanya baca `.env` saat startup | `.env`, `nuxt.config.ts` |
|
||||
| Hardcoded fallback IP di ~25 file | Documented sebagai tech debt. Ideally semua fallback dihapus, hanya andalkan `.env` | `AGENTS.md` |
|
||||
| Banyak klinik tidak muncul di Admin Klinik Ruang | Filter `totalQuota === 0` di `ruangStore.js` menyembunyikan klinik dari API yang belum dikonfigurasi | `stores/ruangStore.js` |
|
||||
|
||||
### Besok
|
||||
- [ ] Eliminasi hardcoded fallback IP lama di semua source files
|
||||
- [ ] Verifikasi koneksi ke endpoint baru (`10.10.150.131:8089`)
|
||||
- [ ] Investigasi klinik yang hilang dari Admin Klinik Ruang karena filter `totalQuota`
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-09 — Fix Admin Klinik Ruang Missing Clinics
|
||||
|
||||
**Sprint/Phase:** Phase 7 — Bug Fixing
|
||||
**Durasi:** ~1 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Investigasi bug di halaman Admin Klinik Ruang (`/adminklinikruang`) — hanya 5 klinik muncul dari total ~23 klinik
|
||||
- Root cause: Filter `totalQuota === 0` di `ruangStore.js` (`fetchRuangFromAPI()`) menyaring klinik yang:
|
||||
1. Tidak ditemukan di clinicStore (default `totalQuota: 0`)
|
||||
2. Belum dikonfigurasi kuotanya di seed data
|
||||
- Fix: Menghapus kedua filter `totalQuota === 0` (line 387-391 dan line 480-483)
|
||||
- User kemudian me-revert fix ini (mengembalikan filter) — menunjukkan filter ini memang diperlukan untuk business logic tertentu
|
||||
|
||||
### Keputusan Teknis
|
||||
> Filter `totalQuota === 0` ternyata berfungsi ganda: (1) menyembunyikan klinik yang memang tidak aktif, (2) tapi juga menyembunyikan klinik baru dari API yang belum dikonfigurasi. Trade-off ini perlu didiskusikan dengan PO.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Admin Klinik Ruang hanya tampil 5 klinik | Ditemukan filter `totalQuota === 0` di `ruangStore.fetchRuangFromAPI()` | `stores/ruangStore.js:387-391, 480-483` |
|
||||
| Fix di-revert oleh user | Filter diperlukan untuk business logic — perlu pendekatan alternatif (misal: tampilkan tapi tandai sebagai "belum dikonfigurasi") | — |
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 2: WebSocket & Typings)
|
||||
|
||||
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
|
||||
**Durasi:** 1 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Membuat composable mandiri `composables/useQueueSync.ts` dan memindahkan lebih dari 200 baris logika *WebSocket* (`onWsMessage`, `connect`, `startAutoSync`) dari dalam *store*.
|
||||
- Menjalankan *scripting* untuk me-resolve sebagian besar error peringatan `implicit any type` dan `catch(error: any)` di dalam `queueStore.ts`.
|
||||
- Menerapkan injeksi dependencies dari `queueStore` ke dalam parameter `useQueueSync()` tanpa memutus *reactivity* dari Pinia state.
|
||||
|
||||
### Keputusan Teknis
|
||||
> **Dependency Injection pada Composable:** Karena fungsionalitas WebSocket membutuhkan akses terhadap state (`allPatients`, `currentProcessingPatient`) dan actions (`fetchPatientsForLoket`, dll), maka variabel-variabel tersebut dilempar (injected) melalui parameter `deps` ke dalam fungsi `useQueueSync(deps)`. Hal ini mencegah masalah *circular dependencies* di ekosistem Nuxt/Pinia.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Pinia-Plugin-PersistedState *type mismatch* pada `paths` config | Menambahkan flag `// @ts-ignore` untuk properti `paths` karena kompatibel secara *runtime* namun melanggar validasi tipe di rilis plugin yang terpasang. | QMD: Technical Debt |
|
||||
|
||||
### Besok
|
||||
- Menguji secara manual interaksi antar *terminal* (Anjungan, Loket, Klinik) untuk memverifikasi fungsionalitas `useQueueSync`.
|
||||
- Menulis Unit Tests (jika platform sudah siap).
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 1)
|
||||
|
||||
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
|
||||
**Durasi:** 1 jam
|
||||
**Status:** 🔄 In Progress
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Mengubah ekstensi file `queueStore.js` menjadi `queueStore.ts`.
|
||||
- Membuat file definisi tipe antrean di `types/queue.ts` (`QueuePatient`).
|
||||
- Mengekstrak raw fetch API calls (Loket, Visit, Update, Selesai Tiket) ke composable mandiri `composables/useQueueAPI.ts`.
|
||||
- Melakukan transisi pada fungsi-fungsi besar (`fetchPatientsForLoket`, `callNext`, `callMultiplePatients`, `processPatient`) di dalam `queueStore.ts` untuk menggunakan `useQueueAPI` guna memangkas boilerplate fetch.
|
||||
|
||||
### Keputusan Teknis
|
||||
> **Incremental Refactoring:** Menghindari pecahnya `queueStore` secara drastis dengan tetap mempertahankan satu *store instance* (`useQueueStore`) namun mengabstraksi beban logikanya keluar (*API* & *Sync layer*). Ini meminimalisasi *blast radius* / *breaking changes* ke komponen UI yang sudah jalan.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Pembengkakan ukuran *store file* (>3500 baris, 145KB). | Mulai memisahkan logika pemanggilan API eksternal dan mendefinisikan strukturnya dengan TypeScript. | QMD: Technical Debt |
|
||||
|
||||
### Besok
|
||||
- Membuat tipe yang strict untuk `state` dan seluruh *method signatures* di `queueStore.ts`.
|
||||
- Mengekstraksi fungsionalitas WebSocket ke `composables/useQueueSync.ts`.
|
||||
- Jika sudah ringan, memecah *store* menjadi `loketQueueStore` dan `clinicQueueStore`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-19 — Fitur Detail Akun Verifikasi
|
||||
|
||||
**Sprint/Phase:** Phase 6 — Verifikasi Akun
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan halaman `DetailAkun.vue` (`400bfcd`)
|
||||
- Update `VerifikasiAkun.vue` untuk integrasi dengan detail akun
|
||||
- Modifikasi konfigurasi Vuetify di `plugins/vuetify.ts`
|
||||
|
||||
### Keputusan Teknis
|
||||
> Pembuatan halaman detail akun terpisah untuk memberikan view lengkap informasi akun yang sedang diverifikasi, memperbaiki user experience admin saat memvalidasi data user.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-08–09 — Voice Over Antrean & Manajemen Loket/Klinik
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi & Kiosk Features
|
||||
**Durasi:** ~12 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi sistem notifikasi suara (voice over) untuk antrean loket dan klinik (`8b9c472`)
|
||||
- Update UI `AdminLoket/[id].vue` dan `Anjungan/AntrianLoket/[id].vue` untuk support voice announcement
|
||||
- Modifikasi `queueStore.js` untuk integrasi status antrean
|
||||
- Resolusi isu WebSocket dan CORS policy pada endpoint api antrean
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan voice synthesis/announcement otomatis untuk pendaftaran antrean agar pasien dengan keterbatasan visual dapat dipanggil secara otomatis dan jelas di area loket maupun klinik.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02–05 — Proxy Routes & Fitur Pindah Klinik
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Integrasi API & Queue System
|
||||
**Durasi:** ~16 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi proxy routes untuk `klinik-api`, `stats-api`, `visit-api` menghindari CORS issues (`0928e78`)
|
||||
- Fitur pindah klinik pada dashboard admin (`f81dd57`)
|
||||
- Penambahan dialog konfirmasi (CheckIn, Unfinished Patient) di queue features
|
||||
- Store lokalisasi menggunakan Pinia untuk loket, klinik, dan queue management (`047b390`)
|
||||
- Pembaruan DEVLOG, DEVPLAN, PRD, QMD, dan EVLOG
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan proxy routes di sisi server (Nitro) untuk mem-bypass CORS ketika memanggil backend API. Hal ini memperkuat keamanan dan menstabilkan fetch data dari aplikasi Vue frontend ke service backend.
|
||||
|
||||
## 2026-05-25 — Perbaikan & Dokumentasi Project
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi & Dokumentasi
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** 🔄 In Progress
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Push perbaikan berbagai bug (`ffdb88d`)
|
||||
- Pembuatan dokumentasi project: PRD, QMD, DEVLOG
|
||||
- Stabilisasi WebSocket sync dengan deterministic client ID
|
||||
- Penambahan polling fallback 30 detik di semua admin interface
|
||||
|
||||
### Keputusan Teknis
|
||||
> Membuat dokumentasi lengkap (PRD, QMD, DEVLOG) untuk meningkatkan maintainability project ke depan. Semua dokumen diisi berdasarkan kondisi aktual project, bukan template kosong.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Dokumentasi project belum ada | Buat PRD, QMD, DEVLOG di folder `docs/` | — |
|
||||
|
||||
### Besok
|
||||
- [ ] Review dan finalisasi dokumen
|
||||
- [ ] Lanjutkan stabilisasi fitur penunjang
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-22 — Stabilisasi WebSocket & Tooltips Dashboard
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Standardisasi header konfigurasi (origin & referer) di `stats-api`, `visit-api`, `klinik-api` ke `http://10.10.150.175:3000`
|
||||
- Penambahan tooltips di komponen Dashboard
|
||||
- Standardisasi WebSocket client ID menjadi deterministic untuk reliable cross-device messaging
|
||||
- Implementasi polling fallback 30 detik di semua admin interface
|
||||
- Audit semua halaman yang bergantung WebSocket
|
||||
|
||||
### Keputusan Teknis
|
||||
> WebSocket client ID diubah dari random ke deterministic agar server bisa menargetkan pesan ke device tertentu. Polling 30 detik ditambahkan sebagai safety net jika WebSocket disconnect — ini menghindari data drift antar display.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Header origin/referer tidak konsisten antar proxy route | Standardisasi ke `http://10.10.150.175:3000` di semua route handler | `server/routes/` |
|
||||
| Data antrean tidak sinkron antar display saat WS drop | Polling fallback 30 detik + deterministic WS client ID | `useWebSocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-21 — Perbaikan Layar Info & Isolasi Loket
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix: info klinik ruang tidak muncul di layar informasi (`ec9dac0`)
|
||||
- Isolasi state `currentProcessingPatient` menggunakan unique storage key per loket
|
||||
- Perbaikan `processNextQueue` dan `callNext` — strict filter berdasarkan `loketId`
|
||||
- Validasi WebSocket event handler agar tidak ada update lintas loket yang tidak sah
|
||||
- Memastikan `allPatients` tetap single source of truth
|
||||
|
||||
### Keputusan Teknis
|
||||
> Masalah kritis: loket A memproses pasien loket B karena `currentProcessingPatient` menggunakan key yang sama. Solusi: setiap loket mendapat unique persisted state key (`currentPatient_loket_{id}`).
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Cross-loket interference pada `currentProcessingPatient` | Unique storage key per loket di persisted state | `queueStore.js` |
|
||||
| `processNextQueue` memproses pasien dari loket lain | Strict filter berdasarkan `loketId` dan service mapping | `useQueue.js` |
|
||||
| WebSocket event mengupdate loket yang salah | Guard check di event handler | `useWebSocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-20 — Subspesialis & Perbaikan Bug Loket
|
||||
|
||||
**Sprint/Phase:** Phase 4 — Fitur Eksekutif
|
||||
**Durasi:** ~7 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan fitur subspesialis di PatientCard (`1446e87`)
|
||||
- Display subspesialis sebagai pill/chip di samping status badge
|
||||
- Perbaikan bug tampilan loket (`f622052`)
|
||||
- UI enhancement: PatientCard menampilkan info ruang/subspesialis secara kondisional
|
||||
|
||||
### Keputusan Teknis
|
||||
> Subspesialis ditampilkan sebagai pill/chip di PatientCard agar admin klinik bisa langsung melihat spesialisasi pasien tanpa perlu buka detail. Conditional rendering — tampilkan ruang ATAU subspesialis, tergantung data yang tersedia.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Info subspesialis tidak tampil di card pasien | Tambah conditional pill/chip di `PatientCard.vue` | `components/features/queue/PatientCard.vue` |
|
||||
| Bug tampilan loket | Fix layout dan data binding | `pages/AdminLoket.vue` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-19 — Docker & Anjungan Eksekutif
|
||||
|
||||
**Sprint/Phase:** Phase 4 — Fitur Eksekutif & Deployment
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan Dockerfile dan docker-compose untuk deployment (`bc74b98`)
|
||||
- Refinement UI anjungan eksekutif — pilihan subspesialis
|
||||
- Peningkatan kontras dan visibilitas button seleksi (border tebal, shadow, hover state)
|
||||
- Implementasi handler function untuk selection state yang robust
|
||||
|
||||
### Keputusan Teknis
|
||||
> Docker ditambahkan untuk standardisasi deployment. Anjungan eksekutif mendapat UI khusus dengan radio-style button yang lebih intuitif — target user adalah pasien dengan tech level rendah, jadi kontras dan ukuran harus maksimal.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Deployment manual dan tidak konsisten | Dockerfile + docker-compose | `Dockerfile`, `docker-compose.yml` |
|
||||
| Button seleksi subspesialis kurang kontras | Thicker border, deeper shadow, distinct hover/selected state | Anjungan pages |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-18 — Optimasi Performa & Memory
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Perbaikan memory usage dan load performance (`cb3310b`)
|
||||
- Verifikasi status kunjungan operasi
|
||||
- Pengecekan semua API endpoint yang sudah terintegrasi
|
||||
- Pembersihan `console.log` verbose di high-frequency path
|
||||
|
||||
### Keputusan Teknis
|
||||
> Mengurangi memory footprint dengan: (1) membersihkan verbose logging di WebSocket handler dan QR scanner, (2) implementasi blacklist untuk endpoint yang terus-menerus gagal agar tidak spam request.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| `doctorStore.js` terus retry endpoint yang 500 | Implementasi blacklist endpoint gagal | `stores/doctorStore.js` |
|
||||
| `console.log` verbose di `useWebSocket.ts` | Cleanup logging di high-frequency path | `composables/useWebSocket.ts` |
|
||||
| Memory usage terus naik | Reduce excessive API retry + cleanup logging | — |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 — Update Hak Akses & Perbaikan Tiket
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update sistem hak akses berbasis Keycloak role & group (`2bf00ef`)
|
||||
- Perbaikan API pembuatan tiket antrean
|
||||
- Standardisasi user role slugs dari Keycloak groups
|
||||
- Implementasi slugification utility untuk role normalization
|
||||
|
||||
### Keputusan Teknis
|
||||
> Role dari Keycloak group path (`/Instalasi STIM/Devops/Superadmin`) di-slugify menjadi URL-friendly (`instalasi-stim-devops-superadmin`) untuk konsistensi di permission API dan internal user data.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Role dari Keycloak tidak URL-friendly | Implementasi slugification utility | `composables/useHakAkses.ts` |
|
||||
| Tiket antrean gagal digenerate | Perbaikan API endpoint pembuatan tiket | `server/api/` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-15 — Update Favicon
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Polish
|
||||
**Durasi:** ~1 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Ganti favicon aplikasi web-antrean
|
||||
- Update konfigurasi `app.head` di `nuxt.config.ts` untuk mengarah ke file icon baru
|
||||
|
||||
### Keputusan Teknis
|
||||
> Favicon diletakkan di folder `public/` dan direferensi via `nuxt.config.ts` → `app.head.link`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-13 — Integrasi Keycloak Role Access
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Verifikasi dan standardisasi akses Keycloak role
|
||||
- Implementasi middleware `auth.ts`, `guest.ts`, `permissions.ts`, `checkPageAccess.ts`
|
||||
- Setup permission store (`permissionStore.ts`)
|
||||
- Integrasi permission API endpoint (`server/api/permission.get.ts`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan 4 layer middleware: (1) `auth.ts` — cek login, (2) `guest.ts` — redirect jika sudah login, (3) `permissions.ts` — cek hak akses halaman, (4) `checkPageAccess.ts` — validasi granular per page. Model ini memastikan defense-in-depth untuk access control.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Role dari Keycloak format path, bukan slug | Konversi ke slug untuk matching di permission API | `middleware/permissions.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-24 — Perbaikan Ambil Tiket
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix bug pengambilan tiket antrean di anjungan (`94ff9f5`)
|
||||
- Perbaikan flow generate nomor antrean
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Tiket gagal digenerate pada kondisi tertentu | Fix logic generate nomor antrean | `composables/useQRGenerator.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-19 — Implementasi Anjungan (Kiosk) & Queue Store
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi modul Anjungan (Kiosk) lengkap (`0c4019c`)
|
||||
- Halaman pilih klinik, klinik ruang, penunjang
|
||||
- Halaman antrian per klinik, klinik ruang, penunjang
|
||||
- Check-in pasien via QR
|
||||
- Admin anjungan
|
||||
- Implementasi Pinia store baru untuk queue management (`4342cdc`)
|
||||
- Integrasi dengan Visit API dan Antrian API
|
||||
- WebSocket integration untuk real-time update
|
||||
- Halaman admin dan kiosk display
|
||||
|
||||
### Keputusan Teknis
|
||||
> `queueStore.js` dibuat sebagai central store untuk semua operasi antrean — ini menjadi single source of truth untuk `allPatients`. Keputusan ini mempermudah sinkronisasi real-time tapi membuat file menjadi sangat besar (saat ini 141KB). Perlu dipecah di masa depan.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-18 — Core Anjungan Display & Verification
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi core display system anjungan (`7d07a4f`)
|
||||
- Display antrean klinik ruang
|
||||
- Display antrean masuk
|
||||
- Display antrean loket
|
||||
- Tambah verification store dan konfigurasi endpoint baru (`f78bbea`)
|
||||
- `verificationApiBaseUrl` → `http://10.10.123.140:8089/api/v1`
|
||||
- `ekstrakExpertiseUrl` endpoint
|
||||
- Fix wsBaseUrl fallback di Nuxt config (`d03fa63`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Dual backend API architecture: Visit API (port 8084) untuk data kunjungan utama, Antrian API (port 8089) untuk verifikasi dan data klinik/dokter. Proxy layer Nitro digunakan untuk bypass CORS dan menyembunyikan IP backend dari client.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-13 — Admin Loket, Klinik Ruang & Queue Management
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi halaman Admin Loket detail (`8c6b3fd`)
|
||||
- Manajemen antrian pasien per loket
|
||||
- Aksi pasien: panggil, skip, recall, selesai
|
||||
- Dialog seleksi (pilih loket, pilih layanan)
|
||||
- Implementasi `AdminKlinikRuang` page (`67a5514`)
|
||||
- Manajemen antrian per klinik ruang
|
||||
- Processing, calling, filtering, global search
|
||||
- Implementasi komprehensif patient queue management (`dbc9054`)
|
||||
- Pinia store untuk queue
|
||||
- API integration
|
||||
- Admin counter page
|
||||
- Tambah komponen `PatientCard`, `CurrentPatientCard` (`95ff830`)
|
||||
- Fitur queue management untuk klinik ruang dan counter (`4df13cb`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Memisahkan queue components menjadi 4 komponen: `PatientCard` (card per pasien), `CurrentPatientCard` (pasien sedang dilayani), `QueueActionsCard` (tombol aksi), `TabelPatientData` (tabel lengkap). Ini memastikan reusability di halaman admin loket, klinik, dan penunjang.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-12 — Anjungan Pages, WebSocket & Queue API
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Introduce halaman Anjungan untuk antrian klinik, klinik ruang, dan counter (`a5ab338`)
|
||||
- Tambah halaman check-in pasien
|
||||
- Implementasi core queue management system dengan WebSocket (`0e3ee37`)
|
||||
- Pinia store untuk queue
|
||||
- WebSocket integration
|
||||
- Halaman check-in dan kiosk display
|
||||
- Update post API klinik dan loket di queueStore (`4985aef`)
|
||||
- Fix status pasien selesai di klinik ruang (`c08941a`)
|
||||
- Various merge dan bugfix
|
||||
|
||||
### Keputusan Teknis
|
||||
> WebSocket dipilih sebagai primary communication channel untuk real-time sync karena latensi < 2 detik krusial di rumah sakit — pasien harus langsung melihat nomor panggil berubah di layar display.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-10–11 — WebSocket Bug Fixing
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Stabilisasi WebSocket
|
||||
**Durasi:** ~12 jam (2 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix bug WebSocket di check-in loket (`b6dc252`)
|
||||
- Fix socket check-in flow (`e686dda`)
|
||||
- Update socket data dan token handling (`fb70237`)
|
||||
- Fix duplikasi data dan ticket menghilang di admin klinik loket (`c02905e`)
|
||||
- Update WS untuk admin loket dan check-in (`a7a654b`)
|
||||
- Fix missing function error (`23164bc`)
|
||||
- Perbaikan sorting pasien di next process (`6af3f58`)
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Duplikasi tiket di admin klinik | Fix logic deduplication di WebSocket handler | `useWebSocket.ts` |
|
||||
| Tiket menghilang setelah check-in | Fix state update flow — pastikan re-render setelah WS event | `queueStore.js` |
|
||||
| Socket check-in tidak trigger update | Fix event listener binding | `composables/useCheckIn.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-09 — Integrasi WebSocket
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Real-time
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Setup dan implementasi WebSocket client (`101dc38`)
|
||||
- Koneksi ke `ws://10.10.123.135:8084/api/v1/ws`
|
||||
- Implementasi `useWebSocket.ts` composable
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan native browser WebSocket API dibungkus composable (`useWebSocket.ts`) daripada library seperti `socket.io` — mengurangi dependency dan lebih ringan untuk environment LAN internal.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-02–06 — API Integration & Display Screen
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Integrasi API
|
||||
**Durasi:** ~20 jam (5 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update tampilan layar screen display (`cf54ded`)
|
||||
- Penambahan API klinik ruang (`a29838e`, `b327997`, `c2d9023`)
|
||||
- Update logika pemanggilan admin loket by payment (`efeb42e`)
|
||||
- Fix HTTPS implementation dan check-in (`c899a71`)
|
||||
- Update session dan WebSocket (`0428017`)
|
||||
- Update status klinik ruang (`6696881`)
|
||||
- Update tampilan anjungan (`329ac9c`)
|
||||
- Fixing admin loket dan blokade loket yang tidak tersedia (`5d139c8`)
|
||||
- Update verifikasi dan lainnya (`2838016`)
|
||||
- Update post API status dan data pasien ruang (`e2a5d43`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Logika pemanggilan admin loket diubah menjadi payment-based — pasien yang sudah bayar diprioritaskan. Loket yang tidak tersedia di-blokade otomatis agar petugas tidak melihat loket yang bukan tanggung jawabnya.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-28–30 — Master Data & API Queue Store
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~12 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update master data berdasarkan jenis layanan (`9f0f6a7`)
|
||||
- Update API antrean masuk dan minor changes (`507f415`)
|
||||
- Fix session dan tampilan screen (`8dd94ed`)
|
||||
- Fix loop dan duplikasi data (`0bd5311`)
|
||||
- Update fetch API (`cccefb0`)
|
||||
- Update layout semua halaman (`ae8b06d`)
|
||||
- Update queueStore dari API (`19633af`)
|
||||
- Minor update anjungan (`59e42f3`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-22–27 — Admin Loket & API Integrasi
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~16 jam (4 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update logika penarikan API dan tampilan anjungan (`e900c5a`)
|
||||
- Update No RM dan desain (`2ccb378`)
|
||||
- Update API master loket dan antrian loket (`75a9638`)
|
||||
- Update anjungan (`8de89cb`)
|
||||
- Update master klinik ruang (`6c08352`)
|
||||
- Update sidebar check-in dan antrian loket (`525322b`)
|
||||
- Update API loket admin (`083fe3e`)
|
||||
- Push update admin loket (`f606045`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-19–21 — Layout, Sidebar, Dashboard & Monitoring
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~20 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update flow klinik ruang dan design consistency (`cbd8f44`)
|
||||
- Update sidebar, profile, dan loket (`f2efd83`)
|
||||
- Update loket dan monitoring (`81b877b`, `c00c18e`)
|
||||
- Update page header komponen (`00bb954`)
|
||||
- Sidebar change (`3db912a`)
|
||||
- Update header, card, dialog (`fde7111`, `531ca10`, `f7a3b20`)
|
||||
- Perubahan update sidebar, dashboard, monitoring, antrean loket, verifikasi (`bb71955`)
|
||||
- Update check-in tampilan dan function (`482294b`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Layout system menggunakan Vuetify navigation drawer (`SideBar.vue`) + custom `PageHeader.vue`. Design consistency diterapkan via SCSS variables (`_variables.scss`, `_colors.scss`) untuk warna, spacing, dan typography.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-15 — Login Page, Check-in & Antrean Masuk
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi login page dan check-in (`e99a0ab`)
|
||||
- Update color palette dan konsultasi pasien (`a8a55b9`)
|
||||
- Tampilan antrean masuk (`ed57e2d`)
|
||||
- Edit manual check-in (`928be7c`)
|
||||
- Update logika pemanggilan, sticky konten, tampilan card (`96c6376`)
|
||||
- Perbaikan generate dan scan tiket QR (`8aa5ab2`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> QR code digunakan untuk tiket antrean — pasien mendapat QR saat ambil antrean, lalu scan QR saat check-in. Library `html5-qrcode` dipilih untuk scanner dan `qrcode.vue` untuk generator.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-12–14 — Loket, Klinik Ruang & Fast Track
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~16 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update layout klinik dan WebSocket layar klinik (`6e7160c`)
|
||||
- Update klinik ruang dan status fast track (`27209de`)
|
||||
- Perubahan tiket dan alur antrean (`5c14227`)
|
||||
- Push loket baru (`42fe62c`)
|
||||
- Loket perbaikan "sedang dilayani" (`48e4aab`)
|
||||
- Anjungan fast track dan tampilan ruang (`3817b2d`)
|
||||
- Perbaikan loket dan total antrean (`a000ba0`, `0a8e11a`, `9ccbaba`)
|
||||
- Check-in tampilan (`0c7b33a`)
|
||||
- Fixing layar loket dan status pasien (`e80032f`)
|
||||
- QRcode update (`6c80c08`)
|
||||
- Update warna dan klinik ruang (`f3e90ad`)
|
||||
- Perubahan antrean masuk (`c5b623f`)
|
||||
- Update seed data dan fungsi pindah/konsul klinik (`89c3549`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-08–09 — Klinik Ruang, QR & Print
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~12 jam (2 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Layar baru antrean klinik dan antrean masuk (`2d3d589`)
|
||||
- Update klinik ruang (`ca5913c`)
|
||||
- Nav items (`8a4bb44`)
|
||||
- Update print admin loket, logika klinik ruang, numbering antrian baru (`676bdc0`)
|
||||
- Update QR function (`22d7205`)
|
||||
- Perubahan format No Antrean (`6d5d565`)
|
||||
- Check-in footers detail (`1b7a142`)
|
||||
- Klinik ruang dan update admin loket (`9ea8300`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Format nomor antrean: `[KodeKlinik]-[NomorUrut]` (contoh: `PDL-001`). Thermal print diimplementasi via `useThermalPrint.ts` composable untuk cetak tiket langsung dari browser ke printer thermal via Web API.
|
||||
|
||||
---
|
||||
|
||||
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary Stats
|
||||
|
||||
| Metrik | Value |
|
||||
|--------|-------|
|
||||
| Total hari dev | ~60+ hari (Jan 2026 — Juli 2026) |
|
||||
| Fase project | 7 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi → Verifikasi → Infrastruktur) |
|
||||
| Fitur selesai | 8 modul utama + dokumentasi + migrasi infra |
|
||||
| Total commits | 90+ commits |
|
||||
| Bug ditemukan | 18+ (WebSocket, loket interference, memory, display sync, missing clinics, API endpoint) |
|
||||
| Bug diselesaikan | 18+ |
|
||||
| Halaman dibuat | 28+ halaman |
|
||||
| Composables | 18 composable |
|
||||
| Pinia Stores | 14 stores |
|
||||
| Komponen | 25+ komponen |
|
||||
| Dokumentasi | AGENTS.md, DEVLOG.md, DEVPLAN.md, PRD.md, API_ENDPOINTS_CLINIC_DOCTOR.md |
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
# 🗺️ DEVPLAN — Development Plan
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.1.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu membuat dev plan:
|
||||
|
||||
```
|
||||
Kamu adalah tech lead senior fullstack (Nuxt 3, Vue 3, TypeScript).
|
||||
|
||||
Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
Deskripsi: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
|
||||
Deadline: Mei 2026 (Go-Live)
|
||||
Tim: 1 Fullstack Engineer (Akbar), PO Tim RSSA
|
||||
|
||||
Fitur yang harus dibangun:
|
||||
1. Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
|
||||
2. Check-in pasien via QR code
|
||||
3. Manajemen antrean loket & display screen
|
||||
4. Manajemen antrean klinik & ruang
|
||||
5. Manajemen antrean penunjang
|
||||
6. Real-time sync via WebSocket
|
||||
7. Dashboard monitoring & statistik
|
||||
8. Manajemen user, master data, dan hak akses (Keycloak)
|
||||
|
||||
Buatkan:
|
||||
1. Breakdown fase development (Phase 0 s/d launch)
|
||||
2. Task list per fase dengan estimasi
|
||||
3. Urutan prioritas fitur (MoSCoW)
|
||||
4. Technical dependencies antar task
|
||||
5. Milestone dan checkpoint
|
||||
|
||||
Format dalam tabel Markdown yang terstruktur.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Timeline
|
||||
|
||||
```
|
||||
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──Phase 7──[Launch]
|
||||
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi Infrastruktur
|
||||
```
|
||||
|
||||
| Fase | Nama | Durasi | Target | Status |
|
||||
|------|------|--------|--------|--------|
|
||||
| Phase 0 | Setup & Arsitektur | 1 Minggu | Awal Jan 2026 | ✅ Done |
|
||||
| Phase 1 | Fondasi (Loket, Klinik, QR) | 3 Minggu | Jan 2026 | ✅ Done |
|
||||
| Phase 2 | Fitur Inti (Anjungan, WS, API) | 3 Minggu | Feb 2026 | ✅ Done |
|
||||
| Phase 3 | Hak Akses & Keycloak | 2 Minggu | Apr 2026 | ✅ Done |
|
||||
| Phase 4 | Fitur Eksekutif & Docker | 1 Minggu | Pertengahan Mei | ✅ Done |
|
||||
| Phase 5 | Stabilisasi & Dokumentasi | 2 Minggu | Akhir Mei 2026 | ✅ Done |
|
||||
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | ✅ Done |
|
||||
| Phase 7 | Infrastruktur & Dokumentasi | Ongoing | Juli 2026 | 🔄 In Progress |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature Prioritization (MoSCoW)
|
||||
|
||||
| Priority | Fitur | Estimasi | Phase |
|
||||
|----------|-------|----------|-------|
|
||||
| 🔴 Must Have | Modul Anjungan (Pilih klinik, ambil tiket, cetak) | 40 jam | 2 |
|
||||
| 🔴 Must Have | Modul Loket (Panggil, skip, recall, next) | 30 jam | 1 & 2 |
|
||||
| 🔴 Must Have | Check-in via QR Code | 20 jam | 1 |
|
||||
| 🔴 Must Have | Sinkronisasi Real-time via WebSocket | 30 jam | 2 |
|
||||
| 🔴 Must Have | Integrasi API Eksternal (Visit API, Antrian API) | 40 jam | 2 |
|
||||
| 🔴 Must Have | Manajemen Hak Akses Keycloak | 25 jam | 3 |
|
||||
| 🟡 Should Have | Antrean Klinik & Penunjang | 30 jam | 1 & 4 |
|
||||
| 🟡 Should Have | Dashboard Monitoring & Statistik | 15 jam | 1 |
|
||||
| 🟡 Should Have | Pilihan Subspesialis (Klinik Eksekutif) | 10 jam | 4 |
|
||||
| 🟢 Could Have | Notifikasi Suara Panggilan (Text-to-Speech) | 15 jam | Future |
|
||||
| ⬜ Won't Have | Aplikasi Mobile Native | — | Out of scope |
|
||||
| ⬜ Won't Have | Integrasi Billing / Pembayaran Langsung | — | Out of scope |
|
||||
|
||||
---
|
||||
|
||||
## 3. Task Breakdown
|
||||
|
||||
### Phase 0 — Setup & Arsitektur
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Init Nuxt 3 + TypeScript | 2 jam | — | ✅ Done |
|
||||
| Setup Vuetify 3 + SCSS variables | 4 jam | Task 1 | ✅ Done |
|
||||
| Setup Pinia state management | 2 jam | Task 1 | ✅ Done |
|
||||
| Setup Vue Router (Nuxt pages/layout) | 3 jam | Task 1 | ✅ Done |
|
||||
| Konfigurasi Proxy API di `nuxt.config.ts` | 3 jam | Task 1 | ✅ Done |
|
||||
|
||||
### Phase 1 — Fondasi (Loket, Klinik, QR)
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| UI Layout, Sidebar, PageHeader | 8 jam | Phase 0 | ✅ Done |
|
||||
| Komponen PatientCard & Tabel | 8 jam | Phase 0 | ✅ Done |
|
||||
| UI Admin Loket & Admin Klinik | 12 jam | Task 2 | ✅ Done |
|
||||
| Check-in Pasien UI & QR Scanner (`html5-qrcode`) | 12 jam | Phase 0 | ✅ Done |
|
||||
| Generator Tiket QR (`qrcode.vue`) | 6 jam | Phase 0 | ✅ Done |
|
||||
| Cetak Tiket Thermal (`useThermalPrint.ts`) | 8 jam | Phase 0 | ✅ Done |
|
||||
|
||||
### Phase 2 — Fitur Inti (Anjungan, WS, API)
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Integrasi Visit API & Antrian API | 16 jam | Phase 0 | ✅ Done |
|
||||
| Setup WebSocket client (`useWebSocket.ts`) | 8 jam | Task 1 | ✅ Done |
|
||||
| Layar Display Antrean (Klinik & Loket) | 12 jam | Task 2 | ✅ Done |
|
||||
| Modul Anjungan Mandiri (UI & Logic) | 16 jam | Task 1 | ✅ Done |
|
||||
| Centralized `queueStore` | 12 jam | Task 1, 2 | ✅ Done |
|
||||
| Bugfix duplikasi data & WS handling | 12 jam | Task 2, 5 | ✅ Done |
|
||||
|
||||
### Phase 3 — Hak Akses & Keycloak
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Konfigurasi Keycloak SSO di frontend | 8 jam | — | ✅ Done |
|
||||
| Middleware Auth & Guest | 4 jam | Task 1 | ✅ Done |
|
||||
| CRUD Master Data (Klinik, Loket, Screen) | 12 jam | Phase 1 | ✅ Done |
|
||||
| Manajemen Hak Akses User/Group (UI & API) | 12 jam | Task 1 | ✅ Done |
|
||||
| Middleware Permission & Page Access Guard | 8 jam | Task 4 | ✅ Done |
|
||||
|
||||
### Phase 4 — Fitur Eksekutif & Docker
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| UI Pemilihan Subspesialis di Anjungan | 8 jam | Phase 2 | ✅ Done |
|
||||
| Conditional display spesialis di PatientCard | 4 jam | Task 1 | ✅ Done |
|
||||
| Setup Dockerfile & docker-compose | 4 jam | — | ✅ Done |
|
||||
| Update konfigurasi environment untuk container | 4 jam | Task 3 | ✅ Done |
|
||||
|
||||
### Phase 5 — Stabilisasi & QA
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Standardisasi WS Client ID (Deterministic) | 4 jam | Phase 2 | ✅ Done |
|
||||
| Polling fallback 30s untuk layar display | 6 jam | Phase 2 | ✅ Done |
|
||||
| Isolasi state loket (`currentProcessingPatient`) | 6 jam | Phase 2 | ✅ Done |
|
||||
| Blacklist handler untuk endpoint gagal 500 | 4 jam | Phase 2 | ✅ Done |
|
||||
| Dokumentasi PRD, QMD, DEVLOG, EVLOG | 8 jam | — | ✅ Done |
|
||||
| Setup Unit Testing & E2E framework | 4 jam | — | ✅ Done |
|
||||
|
||||
### Phase 6 — Verifikasi Akun & Kiosk
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Sistem notifikasi suara (voice over) loket & klinik | 12 jam | Phase 2 | ✅ Done |
|
||||
| Fitur pindah klinik pada dashboard admin | 8 jam | Phase 2 | ✅ Done |
|
||||
| Halaman Detail Akun Verifikasi | 4 jam | Phase 3 | ✅ Done |
|
||||
| Implementasi Proxy Routes (CORS) | 8 jam | Phase 2 | ✅ Done |
|
||||
| Refactoring queueStore.js ke TypeScript | 8 jam | Phase 2 | ✅ Done |
|
||||
| Ekstraksi useQueueSync composable | 4 jam | Phase 2 | ✅ Done |
|
||||
|
||||
### Phase 7 — Infrastruktur & Dokumentasi
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Migrasi API endpoint (123.140 → 150.131) | 2 jam | — | ✅ Done |
|
||||
| Pembuatan Project Knowledge Base (AGENTS.md) | 3 jam | — | ✅ Done |
|
||||
| Update DEVLOG, DEVPLAN, PRD | 2 jam | — | ✅ Done |
|
||||
| Fix Admin Klinik Ruang missing clinics | 1 jam | Phase 2 | ✅ Done |
|
||||
| Eliminasi hardcoded fallback IP di source files | 4 jam | Task 1 | ⏳ Pending |
|
||||
| SQLite config → External API migration | 8 jam | Phase 2 | ⏳ Pending |
|
||||
| Production deployment & monitoring | 8 jam | All | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Dependencies
|
||||
|
||||
```text
|
||||
Nuxt 3 + TypeScript
|
||||
├── Pinia (State Management)
|
||||
│ └── pinia-plugin-persistedstate (localStorage)
|
||||
├── Vuetify 3 (UI Framework)
|
||||
│ └── Material Design Icons
|
||||
├── Nuxt Nitro (Server Engine)
|
||||
│ └── Server API Routes + SQLite (better-sqlite3)
|
||||
├── Auth Layer
|
||||
│ └── Keycloak SSO (OIDC/OAuth 2.0)
|
||||
├── API Layer
|
||||
│ ├── Visit API (10.10.123.135:8084)
|
||||
│ └── Antrian API (10.10.150.131:8089) ← UPDATED
|
||||
└── Real-time Layer
|
||||
└── Native Browser WebSocket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment Setup
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone https://git.rssa.top/arie.bagus.2905/web-antrean
|
||||
cd web-antrean
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy env
|
||||
cp .env.example .env
|
||||
|
||||
# Jalankan dev server dengan host spesifik
|
||||
npm run dev
|
||||
# ATAU
|
||||
npm run _command_dev
|
||||
```
|
||||
|
||||
### Environment Variables (.env)
|
||||
```env
|
||||
AUTH_ORIGIN="http://10.10.150.175:3000"
|
||||
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||
ANTRIAN_API_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
|
||||
VERIFICATION_API_BASE_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
|
||||
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
|
||||
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
|
||||
PROXY_CLIENT_ORIGIN="http://10.10.150.175:3000"
|
||||
PROXY_TARGET_HOST_KLINIK_FALLBACK="10.10.150.131:8089" # UPDATED Juli 2026
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Git Strategy
|
||||
|
||||
### Branch Naming
|
||||
```
|
||||
main → production (stable)
|
||||
Antrean-Code → development / staging
|
||||
feature/xxx → fitur baru (contoh: feature/anjungan-eksekutif)
|
||||
fix/xxx → bug fix (contoh: fix/ws-duplicate)
|
||||
```
|
||||
|
||||
### Commit Convention
|
||||
Saat ini commit message banyak menggunakan free-text (contoh: `push perbaikan`, `update bug ws`). Ke depannya, disarankan menggunakan **Conventional Commits**:
|
||||
```
|
||||
feat: tambah halaman dashboard
|
||||
fix: perbaiki duplikasi antrean di loket
|
||||
chore: update docker config
|
||||
docs: update DEVPLAN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones & Checkpoints
|
||||
|
||||
| Milestone | Kriteria | Target Tanggal | Status |
|
||||
|-----------|----------|----------------|--------|
|
||||
| 🏁 Project Kickoff | Setup Nuxt, Layout, Styling | Jan 2026 | ✅ Done |
|
||||
| 🔌 API & WS Connected | Data mengalir dari backend, WS sync jalan | Feb 2026 | ✅ Done |
|
||||
| 🔐 Auth & Roles | Keycloak jalan, routing terlindungi middleware | Apr 2026 | ✅ Done |
|
||||
| 🏥 Anjungan Ready | Pasien bisa check-in & ambil tiket lancar | Pertengahan Mei | ✅ Done |
|
||||
| 🛡️ Stability Pass | Tidak ada leak, isolasi loket aman, no WS drift | Akhir Mei 2026 | ✅ Done |
|
||||
| 🔄 TypeScript Migration | queueStore refactored ke TS, composable extracted | Juni 2026 | ✅ Done |
|
||||
| 📡 API Endpoint Migration | Antrian API pindah ke server baru (150.131) | Juli 2026 | ✅ Done |
|
||||
| 🚀 Production Go-Live | Deployment docker di server production | Q3 2026 | 🔄 Pending |
|
||||
|
||||
---
|
||||
|
||||
## 8. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.1.0 | 2026-07-10 | Akbar | Phase 7: migrasi API, knowledge base, update docs |
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual |
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
# 🚨 EVLOG — Event & Error Log
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk menganalisis dan mendokumentasikan error:
|
||||
|
||||
```
|
||||
Kamu adalah senior engineer Nuxt 3 + TypeScript. Aku menemukan error berikut:
|
||||
|
||||
Error message: [paste error]
|
||||
Stack trace: [paste stack trace]
|
||||
Context: [dimana terjadi, langkah reproduksi]
|
||||
Tech: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
|
||||
|
||||
Bantu aku:
|
||||
1. Analisis root cause error ini
|
||||
2. Berikan solusi step-by-step
|
||||
3. Sarankan cara mencegah error serupa
|
||||
4. Format hasilnya untuk EVLOG dalam Markdown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Severity Legend
|
||||
|
||||
| Level | Icon | Deskripsi | SLA |
|
||||
|-------|------|-----------|-----|
|
||||
| Critical | 🔴 | App down / data loss / security | < 4 jam |
|
||||
| High | 🟠 | Fitur utama break | < 1 hari |
|
||||
| Medium | 🟡 | Fitur minor terganggu | < 3 hari |
|
||||
| Low | 🟢 | UI/kosmetik | Backlog |
|
||||
|
||||
---
|
||||
|
||||
## Format Entry
|
||||
|
||||
```markdown
|
||||
### [EV-XXX] — [Judul Singkat Error/Event]
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-XXX |
|
||||
| **Tanggal** | YYYY-MM-DD |
|
||||
| **Severity** | 🔴 Critical / 🟠 High / 🟡 Medium / 🟢 Low |
|
||||
| **Environment** | Development / Staging / Production |
|
||||
| **Status** | 🔍 Investigating / 🔧 In Fix / ✅ Resolved / ⏭ Wontfix |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> [paste error message]
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. ...
|
||||
|
||||
**Root Cause:**
|
||||
> ...
|
||||
|
||||
**Solusi / Fix:**
|
||||
> ...
|
||||
|
||||
**Prevention:**
|
||||
> ...
|
||||
|
||||
**Related:** [commit / file]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Log
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
### [EV-012] — Info Klinik Ruang Tidak Muncul di Layar Display
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-012 |
|
||||
| **Tanggal** | 2026-05-21 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Layar informasi klinik ruang menampilkan data kosong — tidak ada info klinik ruang yang muncul.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman display screen klinik ruang
|
||||
2. Data klinik ruang seharusnya muncul
|
||||
3. Layar kosong — tidak ada informasi tampil
|
||||
|
||||
**Root Cause:**
|
||||
> Data binding untuk info klinik ruang tidak ter-update setelah fetch dari API. Kemungkinan reactive state tidak di-watch dengan benar sehingga UI tidak re-render saat data berubah.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Perbaikan data binding dan reactive state untuk informasi klinik ruang pada layar display.
|
||||
|
||||
**Related:** Commit `ec9dac0` — "push perbaikan layar informasi info klinik ruang tidak muncul"
|
||||
|
||||
---
|
||||
|
||||
### [EV-011] — Data Antrean Tidak Sinkron Antar Display Screen
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-011 |
|
||||
| **Tanggal** | 2026-05-22 |
|
||||
| **Severity** | 🔴 Critical |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Display screen di lokasi berbeda menampilkan nomor panggil yang berbeda. Admin memanggil pasien tapi layar display tidak update, atau update terlambat > 10 detik.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka admin loket di PC A
|
||||
2. Buka display screen di TV/monitor B
|
||||
3. Panggil pasien berikutnya di PC A
|
||||
4. Display di monitor B tidak menampilkan nomor panggil terbaru
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket client ID menggunakan random ID, sehingga server tidak bisa menargetkan pesan ke device tertentu secara reliable. Saat koneksi putus lalu reconnect, client ID berubah dan device kehilangan subscription.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```typescript
|
||||
// ❌ Before — random client ID
|
||||
const clientId = `client_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// ✅ After — deterministic client ID berdasarkan page + device
|
||||
const clientId = `${pageType}_${loketId || 'global'}_${deviceFingerprint}`
|
||||
```
|
||||
Ditambahkan juga polling fallback setiap 30 detik sebagai safety net.
|
||||
|
||||
**Prevention:**
|
||||
> Selalu gunakan deterministic identifier untuk WebSocket client. Implementasi polling fallback untuk semua halaman yang bergantung pada real-time data.
|
||||
|
||||
**Related:** Conversation `6fe234cf` — "Stabilizing WebSocket Queue Synchronization"
|
||||
|
||||
---
|
||||
|
||||
### [EV-010] — Cross-Loket Interference pada Patient Processing
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-010 |
|
||||
| **Tanggal** | 2026-05-21 |
|
||||
| **Severity** | 🔴 Critical |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Loket A memproses pasien yang seharusnya milik Loket B. `currentProcessingPatient` menampilkan pasien dari loket yang salah.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login sebagai admin loket A di browser tab 1
|
||||
2. Login sebagai admin loket B di browser tab 2
|
||||
3. Panggil pasien di loket A
|
||||
4. Loket B menampilkan pasien yang sama sebagai "sedang diproses"
|
||||
|
||||
**Root Cause:**
|
||||
> `currentProcessingPatient` menggunakan key persisted state yang sama untuk semua loket. Karena `pinia-plugin-persistedstate` menyimpan ke `localStorage` dengan key yang sama, operasi di satu loket menimpa state loket lain.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ❌ Before — shared key
|
||||
persist: { key: 'currentProcessingPatient' }
|
||||
|
||||
// ✅ After — unique key per loket
|
||||
persist: { key: `currentPatient_loket_${loketId}` }
|
||||
```
|
||||
Ditambahkan juga strict filter di `processNextQueue` dan `callNext` berdasarkan `loketId`, serta guard check di WebSocket event handler.
|
||||
|
||||
**Prevention:**
|
||||
> Setiap state yang bersifat per-instance (per loket, per klinik) HARUS menggunakan unique key. Jangan pernah share persisted state key antar instance.
|
||||
|
||||
**Related:** Conversation `7a37e693` — "Isolating Loket Queue Operations"
|
||||
|
||||
---
|
||||
|
||||
### [EV-009] — Bug Tampilan Loket
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-009 |
|
||||
| **Tanggal** | 2026-05-20 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Layout admin loket tidak render dengan benar — elemen UI tumpang tindih atau alignment salah.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login sebagai admin loket
|
||||
2. Buka halaman `/admin-loket`
|
||||
3. Layout tampilan loket tidak sesuai desain
|
||||
|
||||
**Root Cause:**
|
||||
> CSS layout issue dan data binding yang tidak sinkron dengan state loket.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Perbaikan layout dan data binding di halaman admin loket.
|
||||
|
||||
**Related:** Commit `f622052` — "perbaikan bug tampilan loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-008] — Memory Leak & Request Spam dari doctorStore
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-008 |
|
||||
| **Tanggal** | 2026-05-18 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Internal Server Error (500) — /klinik-api/doctors/...
|
||||
```
|
||||
Error terjadi berulang-ulang tanpa henti, menyebabkan request spam ke backend.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang memuat `doctorStore.js`
|
||||
2. Backend endpoint `/doctors` return 500
|
||||
3. Store terus retry tanpa batas
|
||||
4. Network tab penuh dengan request gagal, memory usage naik terus
|
||||
|
||||
**Root Cause:**
|
||||
> `doctorStore.js` tidak memiliki mekanisme backoff atau blacklist untuk endpoint yang gagal. Setiap kali fetch gagal, store langsung retry tanpa delay, menyebabkan infinite loop request ke server.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ✅ Implementasi blacklist endpoint gagal
|
||||
const blacklistedEndpoints = new Set()
|
||||
|
||||
async function fetchDoctors(endpoint) {
|
||||
if (blacklistedEndpoints.has(endpoint)) {
|
||||
return [] // skip, sudah di-blacklist
|
||||
}
|
||||
try {
|
||||
return await $fetch(endpoint)
|
||||
} catch (error) {
|
||||
if (error.status === 500) {
|
||||
blacklistedEndpoints.add(endpoint)
|
||||
console.warn(`Endpoint blacklisted: ${endpoint}`)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Semua API fetch HARUS memiliki: (1) error handling, (2) retry limit atau backoff, (3) blacklist mechanism untuk persistent failures. Jangan pernah retry tanpa batas.
|
||||
|
||||
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage", commit `cb3310b`
|
||||
|
||||
---
|
||||
|
||||
### [EV-007] — Console.log Verbose Memperlambat Performa
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-007 |
|
||||
| **Tanggal** | 2026-05-18 |
|
||||
| **Severity** | 🟢 Low |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Tidak ada error message, tapi browser DevTools penuh dengan log output, menyebabkan performa menurun terutama di device yang lebih lambat.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang menggunakan WebSocket atau QR scanner
|
||||
2. Buka browser DevTools → Console
|
||||
3. Log membanjir setiap detik dari `useWebSocket.ts` dan QR scanner di `checkIn.vue`
|
||||
|
||||
**Root Cause:**
|
||||
> `console.log` debugging statements di high-frequency code paths (WebSocket message handler yang dipanggil per-detik, QR scanner frame processing) tidak dihapus setelah debugging selesai.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Cleanup semua `console.log` di:
|
||||
> - `composables/useWebSocket.ts` — WebSocket message handler
|
||||
> - `pages/CheckInPasien/checkIn.vue` — QR scanner frame processing
|
||||
> Hanya sisakan `console.warn` dan `console.error` untuk kondisi yang benar-benar perlu.
|
||||
|
||||
**Prevention:**
|
||||
> Gunakan convention: `console.log` hanya untuk debugging sementara, `console.warn`/`console.error` untuk production logging. Tambahkan lint rule atau pre-commit hook untuk mendeteksi `console.log` yang tersisa.
|
||||
|
||||
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage"
|
||||
|
||||
---
|
||||
|
||||
### [EV-006] — Header Origin/Referer Tidak Konsisten di Proxy Routes
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-006 |
|
||||
| **Tanggal** | 2026-05-22 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> CORS error atau origin validation failure saat memanggil backend API melalui proxy routes. Beberapa request berhasil, beberapa gagal secara intermittent.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Panggil API via proxy route `stats-api`, `visit-api`, atau `klinik-api`
|
||||
2. Beberapa request gagal dengan CORS error
|
||||
3. Request yang sama kadang berhasil, kadang gagal
|
||||
|
||||
**Root Cause:**
|
||||
> Setiap proxy route handler (`server/routes/stats-api/`, `visit-api/`, `klinik-api/`) menggunakan header origin dan referer yang berbeda-beda. Backend melakukan validasi origin, dan header yang tidak konsisten menyebabkan intermittent failure.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Standardisasi semua proxy route handler agar menggunakan origin dan referer yang sama:
|
||||
```typescript
|
||||
// ✅ Semua proxy routes menggunakan header yang sama
|
||||
headers: {
|
||||
'Origin': 'http://10.10.150.175:3000',
|
||||
'Referer': 'http://10.10.150.175:3000',
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Buat shared utility function untuk proxy header configuration agar semua route handler menggunakan config yang sama. Hindari copy-paste header config per file.
|
||||
|
||||
**Related:** Conversation `97867794` — "Standardizing Header Configurations Across APIs"
|
||||
|
||||
---
|
||||
|
||||
### [EV-005] — Duplikasi Tiket di Admin Klinik Loket
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-005 |
|
||||
| **Tanggal** | 2026-02-11 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Daftar pasien di admin klinik menampilkan tiket yang sama dua kali. Setelah check-in, tiket pasien menghilang dari daftar.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Pasien ambil antrean di anjungan
|
||||
2. Buka admin klinik loket
|
||||
3. Pasien yang sama muncul 2x di daftar
|
||||
4. Setelah check-in, tiket menghilang dari daftar
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket event handler menambahkan pasien ke `allPatients` tanpa deduplication check. Saat event masuk bersamaan dari multiple sources (initial load + WebSocket), data terduplikasi. Saat check-in, state update menghapus entry yang salah.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ✅ Deduplication check sebelum add ke allPatients
|
||||
function addPatient(patient) {
|
||||
const exists = allPatients.value.find(p => p.nomorAntrean === patient.nomorAntrean)
|
||||
if (!exists) {
|
||||
allPatients.value.push(patient)
|
||||
} else {
|
||||
// Update existing instead of duplicate
|
||||
Object.assign(exists, patient)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Semua operasi mutasi pada `allPatients` harus melalui function yang melakukan deduplication check. Jangan pernah langsung `push` tanpa cek duplikat.
|
||||
|
||||
**Related:** Commit `c02905e` — "fix duplication and ticket disappear in adminklinik loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-004] — WebSocket Check-in Tidak Trigger UI Update
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-004 |
|
||||
| **Tanggal** | 2026-02-10 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Pasien check-in via QR berhasil (API return success), tapi status di admin loket tidak berubah. UI tetap menampilkan pasien sebagai "belum hadir".
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Pasien scan QR di halaman check-in
|
||||
2. API return success — pasien tercatat hadir
|
||||
3. Buka admin loket — status pasien masih "belum hadir"
|
||||
4. Perlu manual refresh untuk melihat update
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket event listener untuk check-in event tidak ter-bind dengan benar. Event `checkin_success` diterima oleh WebSocket, tapi handler tidak melakukan state update ke Pinia store.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix event listener binding di `useWebSocket.ts` — pastikan `checkin_success` event memicu update pada `queueStore.allPatients`.
|
||||
|
||||
**Prevention:**
|
||||
> Setiap WebSocket event type harus memiliki dedicated handler yang ter-test. Buat mapping event → handler yang eksplisit.
|
||||
|
||||
**Related:** Commits `e686dda`, `b6dc252` — "fix socket checkin", "update bug ws checkin loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-003] — HTTPS Implementation Gagal di Check-in
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-003 |
|
||||
| **Tanggal** | 2026-02-02 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Check-in via QR tidak berfungsi saat HTTPS aktif. Kamera QR scanner tidak bisa diakses.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Aktifkan HTTPS di dev server
|
||||
2. Buka halaman check-in
|
||||
3. QR scanner gagal — kamera tidak muncul
|
||||
|
||||
**Root Cause:**
|
||||
> Browser memerlukan HTTPS untuk akses kamera (MediaDevices API), tapi mixed content policy memblokir request ke backend HTTP. Konfigurasi HTTPS tidak lengkap — SSL certificate self-signed tidak dipercaya browser.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix implementasi HTTPS menggunakan `@vitejs/plugin-basic-ssl` di `nuxt.config.ts`. Konfigurasi dev server untuk HTTPS dengan self-signed cert yang di-trust browser.
|
||||
|
||||
**Prevention:**
|
||||
> Saat menggunakan Web API yang memerlukan secure context (kamera, geolocation), pastikan HTTPS sudah configured end-to-end termasuk backend.
|
||||
|
||||
**Related:** Commit `c899a71` — "fix https implementation and checkin"
|
||||
|
||||
---
|
||||
|
||||
### [EV-002] — Loop & Duplikasi Data di Fetch API
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-002 |
|
||||
| **Tanggal** | 2026-01-29 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Data pasien terduplikasi di daftar antrean. Fetch API dipanggil berulang-ulang dalam loop.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang memuat data antrean
|
||||
2. Data pasien muncul berkali-kali
|
||||
3. Network tab menunjukkan API dipanggil berulang-ulang
|
||||
|
||||
**Root Cause:**
|
||||
> `watch` atau `computed` yang bergantung pada reactive state memicu re-fetch setiap kali state berubah, dan hasil fetch mengubah state lagi — menyebabkan infinite loop.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix logic watch/computed agar tidak circular. Tambahkan guard condition untuk mencegah re-fetch saat data sudah di-load.
|
||||
|
||||
**Prevention:**
|
||||
> Hindari circular dependency antara watcher dan state mutation. Gunakan flag `isLoading` untuk mencegah concurrent fetch.
|
||||
|
||||
**Related:** Commit `0bd5311` — "fix loop dan duplicate data"
|
||||
|
||||
---
|
||||
|
||||
### [EV-001] — Session & Tampilan Screen Tidak Sinkron
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-001 |
|
||||
| **Tanggal** | 2026-01-30 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Display screen menampilkan data lama setelah session expire. Setelah re-login, data tidak refresh otomatis.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login dan buka display screen
|
||||
2. Tunggu session expire (1 jam)
|
||||
3. Re-login
|
||||
4. Display masih menampilkan data dari session sebelumnya
|
||||
|
||||
**Root Cause:**
|
||||
> Pinia persisted state menyimpan data lama di `localStorage`. Saat session expire dan user re-login, store tidak di-reset — data lama masih tampil.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Reset persisted state saat login baru. Pastikan display screen melakukan fresh fetch setelah session recovery.
|
||||
|
||||
**Prevention:**
|
||||
> Implementasi session lifecycle hooks: on session expire → clear stale state, on re-login → fresh fetch semua data.
|
||||
|
||||
**Related:** Commit `8dd94ed` — "update fix session dan tampilan screen"
|
||||
|
||||
---
|
||||
|
||||
## 📊 Error Statistics
|
||||
|
||||
| Bulan | Critical | High | Medium | Low | Total |
|
||||
|-------|---------|------|--------|-----|-------|
|
||||
| Jan 2026 | 0 | 1 | 1 | 0 | 2 |
|
||||
| Feb 2026 | 0 | 3 | 1 | 0 | 4 |
|
||||
| Mar 2026 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Apr 2026 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Mei 2026 | 2 | 2 | 1 | 1 | 6 |
|
||||
| **Total** | **2** | **6** | **3** | **1** | **12** |
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Recurring Issues
|
||||
|
||||
> Daftar error yang muncul lebih dari sekali — kandidat untuk refactor/improvement permanen.
|
||||
|
||||
| Error Pattern | Frekuensi | Action |
|
||||
|---------------|-----------|--------|
|
||||
| WebSocket disconnect → data tidak sinkron | 3x (EV-004, EV-011, EV-012) | ✅ Implemented: polling fallback 30 detik + auto-reconnect + deterministic client ID |
|
||||
| Duplikasi data pasien di daftar | 2x (EV-002, EV-005) | ✅ Implemented: deduplication check di `allPatients` mutation |
|
||||
| Session/state stale setelah reconnect | 2x (EV-001, EV-011) | ✅ Implemented: fresh fetch on reconnect + state reset on re-login |
|
||||
| Request spam ke endpoint yang gagal | 1x (EV-008) | ✅ Implemented: blacklist + retry limit. **Monitor untuk recurring** |
|
||||
| Cross-instance state conflict (loket) | 1x (EV-010) | ✅ Implemented: unique persisted state key per instance. **Audit untuk klinik/penunjang** |
|
||||
@@ -0,0 +1,84 @@
|
||||
# 🌿 Git Workflow & Branching Guide
|
||||
|
||||
Dokumen ini berisi panduan *step-by-step* untuk mengelola Git *branch* di project **web-antrean**. Branch utama (main) untuk project ini adalah **`Antrean-Code`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Persiapan Sebelum Mengubah Kode (Membuat Branch Baru)
|
||||
Sangat disarankan untuk tidak mengubah kode secara langsung di `Antrean-Code`. Buatlah branch baru untuk setiap fitur atau perbaikan *bug*.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan Anda berada di branch utama dan kodenya terbaru:
|
||||
```bash
|
||||
git checkout Antrean-Code
|
||||
git pull origin Antrean-Code
|
||||
```
|
||||
2. Buat dan pindah ke branch baru (misalnya `fitur-baru`):
|
||||
```bash
|
||||
git checkout -b nama-fitur-anda
|
||||
```
|
||||
*(Contoh: `git checkout -b tes-refaktoring`)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Menyimpan Perubahan (Commit)
|
||||
Setelah Anda selesai menulis kode, ikuti langkah ini untuk menyimpannya.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Cek file apa saja yang berubah:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Tambahkan file yang ingin disimpan:
|
||||
```bash
|
||||
git add .
|
||||
```
|
||||
*(Tanda `.` artinya menambahkan semua file. Anda juga bisa menyebut nama filenya satu-satu)*
|
||||
3. Lakukan commit dengan pesan yang jelas (mengikuti aturan Conventional Commits di SKILL.md):
|
||||
```bash
|
||||
git commit -m "feat: menambah fitur X"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Menggabungkan Kode ke Branch Utama (Merge ke Antrean-Code)
|
||||
Jika fitur di branch Anda sudah selesai dan teruji, saatnya menggabungkannya (*merge*) kembali ke `Antrean-Code`. Ini adalah tahapan yang baru saja kita lakukan.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan tidak ada perubahan yang belum di-commit di branch Anda (harus clean).
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Pindah ke branch utama (`Antrean-Code`):
|
||||
```bash
|
||||
git checkout Antrean-Code
|
||||
```
|
||||
3. Gabungkan (*merge*) branch fitur Anda (misal `tes-refaktoring`) ke `Antrean-Code`:
|
||||
```bash
|
||||
git merge tes-refaktoring
|
||||
```
|
||||
4. Jika tidak ada konflik, Git akan otomatis menyatukan kodenya. (Jika ada konflik, Anda harus memperbaikinya secara manual di code editor, lalu `git add .` dan `git commit`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Mengunggah Kode ke Server (Push ke Remote)
|
||||
Setelah merge selesai di komputer lokal, Anda harus mengunggah perubahannya ke server GitLab/GitHub.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan Anda berada di branch `Antrean-Code`.
|
||||
2. Push ke remote origin:
|
||||
```bash
|
||||
git push origin Antrean-Code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Membersihkan Branch (Opsional)
|
||||
Jika branch fitur sudah berhasil di-merge dan di-push, Anda bisa menghapusnya agar daftar branch lokal tetap rapi.
|
||||
```bash
|
||||
git branch -d tes-refaktoring
|
||||
```
|
||||
|
||||
---
|
||||
> **Catatan Penting:**
|
||||
> Jangan lupa untuk selalu mengacu pada `docs/DEVLOG.md` saat Anda berhasil menggabungkan fitur besar untuk mencatat apa saja yang baru dirilis.
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
# 📋 PRD — Product Requirements Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.1.0
|
||||
**Status:** `In Progress`
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude / ChatGPT untuk membantu melanjutkan PRD:
|
||||
|
||||
```
|
||||
Kamu adalah product manager senior. Bantu aku mengembangkan PRD untuk project berikut:
|
||||
- Nama project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
- Tujuan utama: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
|
||||
- Target pengguna: Pasien, Admin Loket, Admin Klinik, Admin Penunjang, Superadmin
|
||||
- Tech stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak SSO
|
||||
|
||||
Buatkan:
|
||||
1. Problem statement yang tajam
|
||||
2. User stories tambahan dengan acceptance criteria
|
||||
3. Risiko teknis dan mitigasinya
|
||||
|
||||
Format output dalam Markdown.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### 1.1 Executive Summary
|
||||
Web Antrean adalah aplikasi manajemen antrean rawat jalan berbasis web yang dibangun untuk Rumah Sakit RSSA. Sistem ini mendigitalisasi seluruh alur antrean pasien mulai dari pendaftaran via anjungan mandiri, pemanggilan pasien di loket dan klinik, hingga monitoring real-time oleh administrator. Aplikasi ini kritis karena menggantikan sistem antrean manual yang tidak efisien dan rawan error.
|
||||
|
||||
### 1.2 Problem Statement
|
||||
Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur: pasien tidak mengetahui posisi antrean mereka, petugas loket kesulitan mengelola urutan panggil, dan tidak ada visibilitas real-time lintas unit (loket, klinik, penunjang). Kondisi ini menyebabkan penumpukan pasien, waktu tunggu tidak terprediksi, dan pengalaman buruk bagi pasien.
|
||||
|
||||
### 1.3 Goals & Success Metrics
|
||||
|
||||
| # | Goal | Metrik | Target |
|
||||
|---|------|--------|--------|
|
||||
| 1 | Mengurangi waktu tunggu pasien | Rata-rata waktu tunggu per sesi | < 30 menit |
|
||||
| 2 | Digitalisasi antrean loket & klinik | % proses antrean via sistem | 100% |
|
||||
| 3 | Real-time sync antar perangkat | Latensi update antrean via WebSocket | < 2 detik |
|
||||
| 4 | Kemandirian pasien dalam ambil antrean | % pasien gunakan anjungan mandiri | > 80% |
|
||||
| 5 | Stabilitas sistem | Uptime saat jam operasional | > 99.5% |
|
||||
|
||||
### 1.4 Non-Goals (Out of Scope)
|
||||
- [ ] Integrasi dengan sistem pembayaran / billing
|
||||
- [ ] Manajemen rekam medis (EMR)
|
||||
- [ ] Aplikasi mobile native (iOS/Android)
|
||||
- [ ] Antrean rawat inap (ranap) — fitur ranap admin tersedia tapi belum final
|
||||
- [ ] Sistem appointment/penjadwalan janji temu online
|
||||
|
||||
---
|
||||
|
||||
## 2. Stakeholders
|
||||
|
||||
| Role | Nama | Tanggung Jawab |
|
||||
|------|------|----------------|
|
||||
| Product Owner | Tim RSSA | Prioritas fitur & validasi kebutuhan |
|
||||
| Fullstack Engineer | Akbar | Arsitektur, implementasi, & deployment |
|
||||
| UI/UX Designer | — | Desain antarmuka (dikerjakan oleh engineer) |
|
||||
| QA Engineer | — | Pengujian via Cypress & Vitest |
|
||||
| System Admin | Tim IT RSSA | Infrastruktur, server, & jaringan internal |
|
||||
|
||||
---
|
||||
|
||||
## 3. User Personas
|
||||
|
||||
### 🧑 Persona 1: Pasien Rawat Jalan
|
||||
- **Goals:** Mendapatkan nomor antrean dengan mudah, mengetahui posisi antrean, dan dipanggil tepat waktu
|
||||
- **Pain Points:** Tidak tahu urutan antrean, harus menunggu tanpa informasi, bingung harus ke mana
|
||||
- **Tech Level:** Rendah — menggunakan anjungan layar sentuh di rumah sakit
|
||||
|
||||
### 🧑 Persona 2: Petugas Loket
|
||||
- **Goals:** Memanggil pasien secara urut, memproses lebih dari satu loket secara paralel, melihat daftar antrean real-time
|
||||
- **Pain Points:** Antrean manual rawan konflik antar loket, sulit pantau siapa yang sudah dipanggil
|
||||
- **Tech Level:** Menengah — menggunakan PC/tablet di meja loket
|
||||
|
||||
### 🧑 Persona 3: Admin Klinik / Dokter
|
||||
- **Goals:** Melihat daftar pasien yang akan dilayani di kliniknya, memanggil pasien sesuai urutan, mencatat status kunjungan
|
||||
- **Pain Points:** Tidak tahu berapa pasien tersisa, data pasien tidak sinkron antar perangkat
|
||||
- **Tech Level:** Menengah
|
||||
|
||||
### 🧑 Persona 4: Superadmin / Admin IT
|
||||
- **Goals:** Mengelola master data (klinik, loket, penunjang, screen), mengatur hak akses user, memonitor seluruh antrean
|
||||
- **Pain Points:** Tidak ada dashboard terpusat, perubahan konfigurasi butuh restart sistem
|
||||
- **Tech Level:** Tinggi
|
||||
|
||||
---
|
||||
|
||||
## 4. User Stories
|
||||
|
||||
| ID | Epic | Story | Priority | Acceptance Criteria | Status |
|
||||
|----|------|-------|----------|---------------------|--------|
|
||||
| US-001 | Anjungan | Sebagai pasien, saya ingin mengambil nomor antrean via anjungan mandiri agar tidak perlu antri ke loket | 🔴 High | - [ ] Pasien dapat memilih klinik tujuan<br>- [ ] Sistem generate nomor antrean unik<br>- [ ] Tiket dicetak atau ditampilkan di layar | `Done` |
|
||||
| US-002 | Anjungan | Sebagai pasien eksekutif, saya ingin memilih sub-spesialis di anjungan agar antrean sesuai dokter yang dituju | 🔴 High | - [ ] Daftar subspesialis tampil berdasarkan klinik<br>- [ ] Pilihan tersimpan ke nomor antrean | `Done` |
|
||||
| US-003 | Check-in | Sebagai pasien, saya ingin check-in via scan QR agar kedatangan saya tercatat di sistem | 🔴 High | - [ ] QR scanner aktif via kamera<br>- [ ] Status pasien berubah menjadi "hadir"<br>- [ ] Notifikasi berhasil muncul | `Done` |
|
||||
| US-004 | Loket | Sebagai petugas loket, saya ingin memanggil pasien berikutnya agar antrean berjalan terurut | 🔴 High | - [ ] Tombol "Panggil Berikutnya" tersedia<br>- [ ] Nomor antrean tampil di display screen<br>- [ ] Sync real-time via WebSocket | `Done` |
|
||||
| US-005 | Klinik | Sebagai admin klinik, saya ingin melihat daftar pasien yang akan dilayani agar saya bisa mempersiapkan pelayanan | 🔴 High | - [ ] Daftar pasien tampil dengan status terkini<br>- [ ] Update otomatis tanpa refresh manual | `Done` |
|
||||
| US-006 | Monitoring | Sebagai superadmin, saya ingin melihat dashboard antrean seluruh unit agar dapat memantau kondisi operasional | 🟡 Medium | - [ ] Statistik antrean per unit tersedia<br>- [ ] Data diperbarui real-time | `Done` |
|
||||
| US-007 | Setting | Sebagai superadmin, saya ingin mengatur hak akses per user/group agar keamanan data terjaga | 🟡 Medium | - [ ] CRUD hak akses per role & group Keycloak<br>- [ ] Perubahan langsung efektif tanpa restart | `Done` |
|
||||
| US-008 | Penunjang | Sebagai admin penunjang, saya ingin mengelola antrean unit penunjang (lab, radiologi) agar terpisah dari antrean klinik | 🟡 Medium | - [ ] Antrean penunjang terpisah per unit<br>- [ ] Admin hanya melihat unit penunjangnya | `In Progress` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Functional Requirements
|
||||
|
||||
### 5.1 Modul: Anjungan Mandiri
|
||||
- **FR-01:** Pasien dapat memilih klinik tujuan dari daftar klinik aktif
|
||||
- **FR-02:** Sistem mengenerate nomor antrean berformat `[KodeKlinik]-[Nomor]` (contoh: `PDL-001`)
|
||||
- **FR-03:** Anjungan mendukung pilihan subspesialis untuk klinik eksekutif
|
||||
- **FR-04:** Tiket antrean dapat dicetak via thermal printer
|
||||
- **FR-05:** Anjungan dapat dikonfigurasi per tipe (klinik, penunjang, klinik ruang)
|
||||
|
||||
### 5.2 Modul: Check-in Pasien
|
||||
- **FR-06:** Pasien dapat check-in mandiri via scan QR code
|
||||
- **FR-07:** Sistem memvalidasi QR dan mengupdate status kunjungan ke "hadir"
|
||||
- **FR-08:** Riwayat check-in pasien dapat dilihat oleh petugas
|
||||
|
||||
### 5.3 Modul: Loket
|
||||
- **FR-09:** Petugas loket dapat memanggil pasien berikutnya sesuai urutan antrean
|
||||
- **FR-10:** Setiap loket memiliki state antrean yang terisolasi (tidak interferensi antar loket)
|
||||
- **FR-11:** Petugas dapat skip, recall, atau selesaikan pasien
|
||||
- **FR-12:** Display nomor panggil sinkron via WebSocket ke layar display
|
||||
- **FR-12b:** Pemanggilan antrean dilengkapi dengan notifikasi suara (voice over) otomatis
|
||||
|
||||
### 5.4 Modul: Klinik / Dokter
|
||||
- **FR-13:** Admin klinik melihat daftar pasien berdasarkan klinik yang diampu
|
||||
- **FR-14:** Status pasien (menunggu, dipanggil, selesai) dapat diupdate
|
||||
- **FR-15:** Pemanggilan pasien di klinik sync ke display screen ruangan
|
||||
|
||||
### 5.5 Modul: Dashboard & Monitoring
|
||||
- **FR-16:** Dashboard menampilkan statistik antrean real-time (total, menunggu, selesai)
|
||||
- **FR-17:** Superadmin dapat melihat data seluruh unit sekaligus
|
||||
- **FR-18:** Data diperbarui via WebSocket + polling fallback 30 detik
|
||||
|
||||
### 5.6 Modul: Setting & Master Data
|
||||
- **FR-19:** CRUD Master Klinik, Loket, Penunjang, Klinik Ruang, Screen
|
||||
- **FR-20:** Manajemen hak akses berbasis Keycloak role & group
|
||||
- **FR-21:** Konfigurasi screen display untuk setiap loket/klinik
|
||||
- **FR-22:** Manajemen user login dan sesi
|
||||
|
||||
### 5.7 Modul: Penunjang
|
||||
- **FR-23:** Antrean unit penunjang (lab, radiologi, dll) dikelola terpisah
|
||||
- **FR-24:** Admin penunjang hanya mengakses unit yang menjadi tanggung jawabnya
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
| Kategori | Requirement | Target |
|
||||
|----------|-------------|--------|
|
||||
| Performance | WebSocket latency update antrean | < 2 detik |
|
||||
| Performance | Waktu load halaman utama | < 3 detik |
|
||||
| Performance | Polling fallback interval | 30 detik |
|
||||
| Security | Auth method | Keycloak SSO (OAuth 2.0 / OIDC) |
|
||||
| Security | Session duration | 1 jam (configurable) |
|
||||
| Security | Hak akses berbasis role & group | Role-Based + Group-Based |
|
||||
| Availability | Uptime jam operasional (06.00–21.00) | > 99.5% |
|
||||
| Accessibility | Anjungan — touch target size | > 44px (mudah dioperasikan pasien) |
|
||||
| Scalability | Jumlah koneksi WebSocket simultan | > 50 device sekaligus |
|
||||
| Compatibility | Browser support | Chrome, Edge (terbaru) |
|
||||
| Network | Operasi di jaringan LAN internal RS | ✅ Fully LAN-based |
|
||||
|
||||
---
|
||||
|
||||
## 7. Technical Specifications
|
||||
|
||||
### 7.1 Tech Stack
|
||||
|
||||
| Layer | Teknologi |
|
||||
|-------|-----------|
|
||||
| Frontend Framework | Nuxt 3 (SSR — server-side rendering) |
|
||||
| UI Framework | Vue 3 · Vuetify 3 |
|
||||
| Language | TypeScript · JavaScript |
|
||||
| State Management | Pinia + pinia-plugin-persistedstate |
|
||||
| Styling | SCSS (Vuetify override) + Material Design Icons |
|
||||
| Real-time | WebSocket (native browser API via `useWebSocket.ts`) |
|
||||
| Auth | Keycloak SSO (OAuth 2.0 / OIDC) |
|
||||
| Charts | Chart.js · vue-chartjs · nuxt-charts |
|
||||
| QR | html5-qrcode (scanner) · qrcode.vue (generator) |
|
||||
| Print | Thermal printer via `useThermalPrint.ts` |
|
||||
| Date | Day.js |
|
||||
| Icons | FontAwesome · Material Design Icons |
|
||||
| Testing | Vitest · Cypress |
|
||||
| Fonts | Inter (Google Fonts) |
|
||||
|
||||
### 7.2 Backend APIs (Eksternal)
|
||||
|
||||
| Service | Base URL | Keterangan |
|
||||
|---------|----------|------------|
|
||||
| Visit API | `http://10.10.123.135:8084/api/v1` | Data kunjungan & antrean utama |
|
||||
| Antrian API (Klinik) | `http://10.10.150.131:8089/api/v1` | Verifikasi & data klinik/dokter |
|
||||
| WebSocket | `ws://10.10.123.135:8084/api/v1/ws` | Real-time queue update |
|
||||
|
||||
### 7.3 Arsitektur Sistem
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Nuxt 3 Frontend (SSR/CSR) │
|
||||
│ Pages · Components · Stores (Pinia) │
|
||||
│ Composables: useWebSocket · useQueue · │
|
||||
│ useCheckIn · useThermalPrint · useQRScanner │
|
||||
└──────────┬──────────────────┬────────────────┘
|
||||
│ $fetch / useFetch │ WebSocket
|
||||
┌──────────▼──────────┐ ┌────▼───────────────┐
|
||||
│ Nuxt Server (Nitro) │ │ WebSocket Server │
|
||||
│ server/api/ │ │ ws://10.10.123. │
|
||||
│ SQLite (users.db) │ │ 135:8084/api/v1/ws│
|
||||
│ (Config + Users) │ └────────────────────┘
|
||||
└──────────┬──────────┘
|
||||
│ HTTP Proxy (CORS bypass)
|
||||
┌──────────▼──────────────────────────────────┐
|
||||
│ Backend Services (Eksternal) │
|
||||
│ Visit API (8084) · Antrian API (8089) │
|
||||
│ Keycloak SSO (auth.rssa.top) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.4 Proxy Routes (Nuxt Server)
|
||||
|
||||
| Proxy Path | Target Backend | Keterangan |
|
||||
|------------|---------------|------------|
|
||||
| `/visit-api/**` | `http://10.10.123.135:8084/api/v1/**` | Data kunjungan pasien |
|
||||
| `/klinik-api/**` | `http://10.10.150.131:8089/api/v1/**` | Data klinik & dokter |
|
||||
| `/stats-api/**` | `http://10.10.123.135:8084/api/v1/**` | Statistik dashboard |
|
||||
|
||||
### 7.5 Struktur Folder
|
||||
|
||||
```
|
||||
web-antrean/
|
||||
├── assets/scss/ ← Global styles & variables
|
||||
├── components/ ← Reusable UI components
|
||||
├── composables/ ← useWebSocket, useQueue, useCheckIn,
|
||||
│ useThermalPrint, useQRScanner, useAPI
|
||||
├── layouts/ ← Layout default & admin
|
||||
├── middleware/ ← Auth & permission guards
|
||||
├── pages/ ← Semua halaman (lihat seksi 8.1)
|
||||
├── server/
|
||||
│ ├── api/ ← Internal API (auth, hak-akses, queue, users)
|
||||
│ └── routes/ ← Proxy routes (visit-api, klinik-api, stats-api)
|
||||
├── stores/ ← Pinia stores (queue, clinic, loket, dll)
|
||||
├── types/ ← TypeScript interfaces & types
|
||||
├── public/ ← Static assets (favicon, logo)
|
||||
├── nuxt.config.ts ← Konfigurasi utama Nuxt
|
||||
└── .env ← Environment variables (tidak di-commit)
|
||||
```
|
||||
|
||||
### 7.6 Internal API Endpoints (Nuxt Nitro)
|
||||
|
||||
| Method | Endpoint | Deskripsi | Auth |
|
||||
|--------|----------|-----------|------|
|
||||
| GET | `/api/permission` | Ambil permissions berdasarkan role & group | ✅ |
|
||||
| GET | `/api/hak-akses` | Daftar semua hak akses | ✅ |
|
||||
| POST | `/api/hak-akses` | Buat hak akses baru | ✅ |
|
||||
| PATCH | `/api/hak-akses/:id` | Update hak akses | ✅ |
|
||||
| DELETE | `/api/hak-akses/:id` | Hapus hak akses | ✅ |
|
||||
| GET | `/api/users/list` | Daftar user dari Keycloak | ✅ |
|
||||
| POST | `/api/auth/login` | Login via Keycloak SSO | ❌ |
|
||||
| POST | `/api/auth/logout` | Logout & invalidate session | ✅ |
|
||||
| POST | `/api/external/validate-token` | Validasi JWT token eksternal | ✅ |
|
||||
| GET | `/api/config/...` | Konfigurasi aplikasi | ✅ |
|
||||
|
||||
### 7.7 TypeScript Types Utama
|
||||
|
||||
```typescript
|
||||
// types/user.ts
|
||||
export interface User {
|
||||
id: string
|
||||
namaLengkap: string
|
||||
namaUser: string
|
||||
email: string
|
||||
tipeUser: string
|
||||
roles: string[]
|
||||
groups: string[]
|
||||
lastLogin: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// types/queue.ts
|
||||
export interface QueuePatient {
|
||||
nomorAntrean: string
|
||||
namaPassien: string
|
||||
noRM: string
|
||||
klinik: string
|
||||
status: 'menunggu' | 'dipanggil' | 'selesai' | 'skip'
|
||||
loketId?: string
|
||||
subspesialis?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// types/permission.ts
|
||||
export interface Permission {
|
||||
id: number
|
||||
pagename: string
|
||||
read: boolean
|
||||
create: boolean
|
||||
update: boolean
|
||||
delete: boolean
|
||||
disable: boolean
|
||||
active: boolean
|
||||
level: number
|
||||
parent: number | null
|
||||
}
|
||||
|
||||
// types/api.ts
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. UI/UX
|
||||
|
||||
### 8.1 Halaman & Routes
|
||||
|
||||
| Halaman | Route | Auth | Role |
|
||||
|---------|-------|------|------|
|
||||
| Landing / Home | `/` | ❌ | Public |
|
||||
| Login | `/login-page` | ❌ | Public |
|
||||
| Dashboard | `/dashboard` | ✅ | Superadmin |
|
||||
| Admin Klinik | `/admin-klinik` | ✅ | Admin Klinik |
|
||||
| Admin Loket | `/admin-loket` | ✅ | Admin Loket |
|
||||
| Admin Penunjang | `/admin-penunjang` | ✅ | Admin Penunjang |
|
||||
| Buat Antrean | `/buat-antrean` | ✅ | Loket |
|
||||
| Klinik Ruang Admin | `/klinik-ruang-admin` | ✅ | Admin Klinik Ruang |
|
||||
| Ranap Admin | `/ranap-admin` | ✅ | Admin Ranap |
|
||||
| Anjungan Utama | `/anjungan` | ❌ | Kiosk |
|
||||
| Admin Anjungan | `/anjungan/admin-anjungan` | ✅ | Superadmin |
|
||||
| Antrian Klinik | `/anjungan/antrian-klinik` | ❌ | Kiosk |
|
||||
| Antrian Klinik Ruang | `/anjungan/antrian-klinik-ruang` | ❌ | Kiosk |
|
||||
| Antrian Penunjang | `/anjungan/antrian-penunjang` | ❌ | Kiosk |
|
||||
| Antrean Masuk Screen | `/anjungan/antrean-masuk` | ❌ | Display |
|
||||
| Check In Pasien | `/check-in-pasien/check-in` | ❌ | Kiosk |
|
||||
| Data Pasien | `/data-pasien` | ✅ | Admin |
|
||||
| Edit Data Pasien | `/data-pasien/edit/:id` | ✅ | Admin |
|
||||
| Monitoring Pasien | `/monitoring-pasien/monitoring-pasien` | ✅ | Admin |
|
||||
| Detail Pasien | `/monitoring-pasien/pasien/:id` | ✅ | Admin |
|
||||
| Profil | `/profile/profil` | ✅ | Semua |
|
||||
| User Login | `/setting/user-login` | ✅ | Superadmin |
|
||||
| Hak Akses | `/setting/hak-akses` | ✅ | Superadmin |
|
||||
| Master Klinik | `/setting/master-klinik` | ✅ | Superadmin |
|
||||
| Master Klinik Ruang | `/setting/master-klinik-ruang` | ✅ | Superadmin |
|
||||
| Master Loket | `/setting/master-loket` | ✅ | Superadmin |
|
||||
| Master Penunjang | `/setting/master-penunjang` | ✅ | Superadmin |
|
||||
| Screen Settings | `/setting/screen` | ✅ | Superadmin |
|
||||
| Verifikasi Akun | `/verifikasi-akun/verifikasi-akun` | ❌ | Public |
|
||||
| Detail Akun Verifikasi | `/verifikasi-akun/detail-akun` | ✅ | Admin |
|
||||
|
||||
### 8.2 Breakpoints
|
||||
|
||||
| Nama | Size | Keterangan |
|
||||
|------|------|------------|
|
||||
| Mobile | < 640px | Tidak diprioritaskan (akses via PC/tablet) |
|
||||
| Tablet | 640–1024px | Anjungan & loket (tablet) |
|
||||
| Desktop | > 1024px | Admin & monitoring |
|
||||
|
||||
### 8.3 Design System
|
||||
- **Framework UI:** Vuetify 3 (Material Design 3)
|
||||
- **Font:** Inter (400, 500, 600, 700)
|
||||
- **Icons:** Material Design Icons (`@mdi/font`) + FontAwesome
|
||||
- **Color scheme:** Mengikuti theme Vuetify (light/dark configurable)
|
||||
- **SCSS Variables:** Didefinisikan di `assets/scss/_variables.scss` & `_colors.scss`
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigasi |
|
||||
|------|-----------|--------|----------|
|
||||
| WebSocket disconnect saat jaringan LAN tidak stabil | Tinggi | Tinggi | Polling fallback 30 detik + auto-reconnect logic |
|
||||
| Backend API eksternal down (Visit API / Antrian API) | Sedang | Tinggi | Error handling graceful, retry logic, blacklist endpoint gagal |
|
||||
| Konflik antrean antar loket (race condition) | Sedang | Tinggi | State isolasi per `loketId`, strict filtering di `processNextQueue` |
|
||||
| Sesi Keycloak expire saat jam operasional | Sedang | Sedang | Session duration dikonfigurasi 1 jam, refresh token otomatis |
|
||||
| Thermal printer tidak kompatibel di semua device | Rendah | Sedang | Uji di device target sebelum go-live, fallback ke tampilan layar |
|
||||
| Data antrean tidak sinkron antar display screen | Sedang | Tinggi | WebSocket deterministic client ID + polling fallback |
|
||||
| Kapasitas WebSocket server saat pasien peak | Rendah | Tinggi | Monitor jumlah koneksi, koordinasi dengan tim backend |
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment
|
||||
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| Server | VPS / Server internal RSSA |
|
||||
| Domain dev | `http://10.10.150.175:3000` |
|
||||
| Domain staging | `https://antrean.dev.rssa.id` |
|
||||
| Domain prod | `https://antrean.rssa.id` |
|
||||
| Containerisasi | Docker + docker-compose |
|
||||
| Auth Server | Keycloak (`https://auth.rssa.top/realms/sandbox`) |
|
||||
| Build command | `nuxt build` |
|
||||
| Start command | `node .output/server/index.mjs` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.1.0 | 2026-07-10 | Akbar | Update API endpoints (150.131), arsitektur diagram, proxy routes, Phase 7 |
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial PRD — dibuat berdasarkan kondisi project aktual |
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
# ✅ QMD — Quality Management Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu QA planning:
|
||||
|
||||
```
|
||||
Kamu adalah QA engineer senior untuk project Nuxt 3 + Vue 3 + TypeScript.
|
||||
Project: Web Antrean — Sistem manajemen antrean rawat jalan RSSA.
|
||||
|
||||
Fitur utama:
|
||||
- Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
|
||||
- Check-in pasien via QR code
|
||||
- Manajemen antrean loket (panggil, skip, recall, selesai)
|
||||
- Manajemen antrean klinik & penunjang
|
||||
- Real-time sync via WebSocket + polling fallback
|
||||
- Dashboard monitoring & statistik
|
||||
- Setting: master data (klinik, loket, penunjang, screen), hak akses (Keycloak role/group)
|
||||
- Cetak tiket via thermal printer
|
||||
|
||||
Bantu aku membuat:
|
||||
1. Test plan lengkap (unit, integration, E2E)
|
||||
2. Test cases untuk fitur di atas
|
||||
3. Definition of Done (DoD) per story
|
||||
4. Checklist code review untuk Vue 3 + TypeScript
|
||||
5. Standar kualitas kode (naming, linting, typing)
|
||||
|
||||
Format dalam tabel Markdown. Tool: Vitest, Cypress, Vue Test Utils, happy-dom.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Quality Objectives
|
||||
|
||||
| Objektif | Target | Cara Ukur |
|
||||
|----------|--------|-----------|
|
||||
| Test Coverage (Unit) | > 60% | Vitest coverage report (`npx vitest run --coverage`) |
|
||||
| Bug Rate (prod) | < 5 bug/sprint | Manual tracking / issue log |
|
||||
| Code Review | 100% PR di-review | Git workflow |
|
||||
| TypeScript strict | Minimal `any` type | ESLint + `tsc --noEmit` |
|
||||
| WebSocket Reliability | > 99% message delivery | Monitoring log + polling fallback |
|
||||
| Page Load | < 3 detik | Lighthouse / manual timing di jaringan LAN |
|
||||
|
||||
---
|
||||
|
||||
## 2. Testing Strategy
|
||||
|
||||
### 2.1 Piramida Testing
|
||||
```
|
||||
▲
|
||||
/E2E\ ← Cypress (browser real)
|
||||
/──────\
|
||||
/ Integ \ ← Vitest + Nuxt Test Utils
|
||||
/──────────\
|
||||
/ Unit Test \ ← Vitest + Vue Test Utils + happy-dom
|
||||
/______________\
|
||||
```
|
||||
|
||||
### 2.2 Test Toolchain
|
||||
|
||||
| Tipe | Tool | Config File | Status |
|
||||
|------|------|-------------|--------|
|
||||
| Unit | Vitest + happy-dom | `vitest.config.ts` | ✅ Terkonfigurasi |
|
||||
| Component | Vue Test Utils (`@vue/test-utils`) | — | ✅ Terinstal |
|
||||
| E2E | Cypress | `cypress.config.ts` | ✅ Terkonfigurasi |
|
||||
| Component (Cypress) | Cypress Component Testing | `cypress.config.ts` → `component` | ✅ Terkonfigurasi |
|
||||
| Linting | ESLint (Nuxt preset) | `eslint.config.mjs` | ✅ Terkonfigurasi |
|
||||
| Type Check | TypeScript (via Nuxt) | `tsconfig.json` → extends `.nuxt/tsconfig.json` | ✅ |
|
||||
| Test Environment | happy-dom | `vitest.config.ts` → `environment: 'happy-dom'` | ✅ |
|
||||
|
||||
### 2.3 Test Commands
|
||||
|
||||
```bash
|
||||
# Unit & Component Tests
|
||||
npm run test # vitest (watch mode)
|
||||
npm run test:ui # vitest --ui (browser UI)
|
||||
|
||||
# E2E Tests
|
||||
npm run cypress:open # Cypress interactive
|
||||
npm run cypress:run # Cypress headless
|
||||
|
||||
# Linting
|
||||
npx eslint .
|
||||
|
||||
# Type Check
|
||||
npx nuxi typecheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Cases
|
||||
|
||||
### 3.1 Unit Tests — Composables
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-001 | `useAuth.ts` | Login — mengembalikan user data setelah Keycloak auth | `user.value` tidak null, memiliki `id`, `roles`, `groups` | `Todo` |
|
||||
| UT-002 | `useAuth.ts` | Logout — session di-clear dan redirect ke login page | `user.value` menjadi null, navigasi ke `/LoginPage` | `Todo` |
|
||||
| UT-003 | `useQueue.js` | `processNextQueue()` — mengambil pasien berikutnya sesuai loketId | Return pasien dengan status `menunggu` dan `loketId` yang cocok | `Todo` |
|
||||
| UT-004 | `useQueue.js` | `processNextQueue()` — skip pasien dari loket lain | Pasien dari loket lain tidak terproses | `Todo` |
|
||||
| UT-005 | `useWebSocket.ts` | Koneksi sukses — state connected | `isConnected.value === true` setelah open event | `Todo` |
|
||||
| UT-006 | `useWebSocket.ts` | Auto-reconnect setelah disconnect | Reconnect attempt dalam < 5 detik | `Todo` |
|
||||
| UT-007 | `useCheckIn.ts` | Check-in valid QR — update status pasien | Status pasien berubah ke `hadir`, return success | `Todo` |
|
||||
| UT-008 | `useCheckIn.ts` | Check-in invalid QR — error handling | Return error message, status tidak berubah | `Todo` |
|
||||
| UT-009 | `useQRScanner.ts` | Inisialisasi scanner — kamera aktif | Scanner instance terbuat tanpa error | `Todo` |
|
||||
| UT-010 | `useHakAkses.ts` | Fetch permissions — mapping role ke menu | Menu permissions sesuai dengan role & group user | `Todo` |
|
||||
| UT-011 | `useThermalPrint.ts` | Generate tiket — format nomor antrean benar | Output mengandung kode klinik + nomor urut | `Todo` |
|
||||
| UT-012 | `useClinicAPI.ts` | Fetch daftar klinik — return data klinik aktif | Array klinik tidak kosong, setiap item punya `id` & `nama` | `Todo` |
|
||||
|
||||
### 3.2 Unit Tests — Stores (Pinia)
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-013 | `queueStore.js` | `allPatients` — menyimpan & mengembalikan daftar pasien | State `allPatients` terisi array setelah fetch | `Todo` |
|
||||
| UT-014 | `queueStore.js` | `currentProcessingPatient` — isolasi per loket | Setiap loket punya key unik di persisted state | `Todo` |
|
||||
| UT-015 | `clinicStore.js` | Fetch daftar klinik dari API | `clinics` terisi data dari klinik-api | `Todo` |
|
||||
| UT-016 | `doctorStore.js` | Blacklist endpoint gagal | Endpoint yang 500 di-blacklist, tidak di-retry spam | `Todo` |
|
||||
| UT-017 | `loketStore.js` | State loket terisolasi antar loket | Operasi di loket A tidak mempengaruhi loket B | `Todo` |
|
||||
| UT-018 | `masterStore.js` | CRUD master klinik | Create, read, update, delete berjalan tanpa error | `Todo` |
|
||||
| UT-019 | `permissionStore.ts` | Load permission sesuai role | Permission loaded dan accessible via getter | `Todo` |
|
||||
|
||||
### 3.3 Unit Tests — Middleware
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-020 | `auth.ts` | User belum login → redirect ke `/LoginPage` | `navigateTo('/LoginPage')` dipanggil | `Todo` |
|
||||
| UT-021 | `auth.ts` | User sudah login → lanjut ke halaman tujuan | Tidak ada redirect | `Todo` |
|
||||
| UT-022 | `guest.ts` | User sudah login akses `/LoginPage` → redirect ke `/dashboard` | `navigateTo('/dashboard')` dipanggil | `Todo` |
|
||||
| UT-023 | `permissions.ts` | User tanpa akses ke halaman → redirect/block | Akses ditolak, redirect ke halaman authorized | `Todo` |
|
||||
| UT-024 | `checkPageAccess.ts` | Validasi hak akses per halaman berdasarkan group | Halaman hanya bisa diakses sesuai permission | `Todo` |
|
||||
|
||||
### 3.4 Component Tests
|
||||
|
||||
| ID | Komponen | Skenario | Expected | Status |
|
||||
|----|----------|----------|----------|--------|
|
||||
| CT-001 | `PatientCard.vue` | Render data pasien lengkap | Nama, noRM, nomor antrean, status, subspesialis tampil | `Todo` |
|
||||
| CT-002 | `PatientCard.vue` | Status badge warna sesuai status | `menunggu` = kuning, `dipanggil` = biru, `selesai` = hijau | `Todo` |
|
||||
| CT-003 | `CurrentPatientCard.vue` | Tampilkan pasien yang sedang diproses | Data pasien aktif tampil dengan aksi (selesai, skip) | `Todo` |
|
||||
| CT-004 | `QueueActionsCard.vue` | Tombol aksi antrean (panggil, skip, recall) | Semua tombol render dan emit event yang benar | `Todo` |
|
||||
| CT-005 | `TabelPatientData.vue` | Render tabel daftar pasien | Kolom: nama, noRM, antrean, status, aksi tampil benar | `Todo` |
|
||||
| CT-006 | `SideBar.vue` | Menu render sesuai hak akses user | Menu yang tidak diizinkan tidak tampil | `Todo` |
|
||||
| CT-007 | `PageHeader.vue` | Render judul halaman dan breadcrumb | Judul dan navigasi sesuai route aktif | `Todo` |
|
||||
| CT-008 | `AppSnackbar.vue` | Notifikasi muncul dan auto-dismiss | Snackbar tampil 3 detik lalu hilang | `Todo` |
|
||||
| CT-009 | `SelectionDialog.vue` | Dialog pilihan dengan konfirmasi | Pilihan terseleksi, emit event saat konfirmasi | `Todo` |
|
||||
| CT-010 | `ProfileMenu.vue` | Tampil info user dan tombol logout | Nama user tampil, klik logout memanggil `useAuth().logout()` | `Todo` |
|
||||
|
||||
### 3.5 E2E Tests (Cypress)
|
||||
|
||||
| ID | Flow | Steps | Expected | Status |
|
||||
|----|------|-------|----------|--------|
|
||||
| E2E-001 | Login | 1. Buka `/` 2. Redirect ke `/LoginPage` 3. Klik login Keycloak 4. Isi credentials | Redirect ke `/dashboard`, user session aktif | `Skeleton` |
|
||||
| E2E-002 | Anjungan — Ambil Antrean | 1. Buka `/anjungan` 2. Pilih klinik 3. Konfirmasi | Nomor antrean di-generate, tiket tampil | `Todo` |
|
||||
| E2E-003 | Anjungan Eksekutif — Pilih Subspesialis | 1. Buka `/anjungan` 2. Pilih klinik eksekutif 3. Pilih subspesialis 4. Konfirmasi | Antrean tercipta dengan subspesialis terpilih | `Todo` |
|
||||
| E2E-004 | Check-in QR | 1. Buka `/check-in-pasien/check-in` 2. Scan QR valid | Status pasien update ke "hadir", notifikasi sukses | `Todo` |
|
||||
| E2E-005 | Loket — Panggil Pasien | 1. Login sebagai admin loket 2. Buka `/admin-loket` 3. Klik "Panggil Berikutnya" | Pasien berikutnya tampil di current patient card | `Todo` |
|
||||
| E2E-006 | Loket — Skip & Recall | 1. Panggil pasien 2. Klik skip 3. Klik recall | Pasien di-skip lalu bisa di-recall kembali | `Todo` |
|
||||
| E2E-007 | Klinik — Lihat Daftar Pasien | 1. Login sebagai admin klinik 2. Buka `/admin-klinik` | Daftar pasien klinik tampil sesuai klinik user | `Todo` |
|
||||
| E2E-008 | Dashboard — Statistik | 1. Login sebagai superadmin 2. Buka `/dashboard` | Chart statistik dan data antrean tampil | `Todo` |
|
||||
| E2E-009 | Setting — CRUD Master Klinik | 1. Buka `/setting/master-klinik` 2. Tambah klinik 3. Edit 4. Hapus | Data klinik berhasil CRUD tanpa error | `Todo` |
|
||||
| E2E-010 | Setting — Hak Akses | 1. Buka `/setting/hak-akses` 2. Pilih role & group 3. Set permission 4. Simpan | Hak akses tersimpan dan efektif | `Todo` |
|
||||
| E2E-011 | WebSocket Sync | 1. Buka admin loket di tab A 2. Buka display screen di tab B 3. Panggil pasien di tab A | Tab B menampilkan nomor panggil dalam < 2 detik | `Todo` |
|
||||
| E2E-012 | Auth Guard | 1. Tanpa login, akses `/dashboard` | Redirect ke `/LoginPage` | `Todo` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Definition of Done (DoD)
|
||||
|
||||
Sebuah task/story dianggap **Done** jika:
|
||||
|
||||
- [ ] Code sudah diimplementasi dan berjalan di dev server
|
||||
- [ ] Unit test ditulis untuk logic kritis (composable, store)
|
||||
- [ ] TypeScript: tidak ada error pada `npx nuxi typecheck`
|
||||
- [ ] ESLint: tidak ada error (`npx eslint .`)
|
||||
- [ ] Code review dilakukan minimal 1 orang
|
||||
- [ ] Tested manual di browser Chrome (target utama)
|
||||
- [ ] WebSocket sync diverifikasi antar device (jika fitur terkait real-time)
|
||||
- [ ] Responsive layout dicek untuk tablet (anjungan) dan desktop (admin)
|
||||
- [ ] Tidak ada `console.log` debugging yang tertinggal di production path
|
||||
- [ ] State antrean terisolasi per loket (jika fitur terkait loket)
|
||||
- [ ] Polling fallback 30 detik berfungsi sebagai safety net
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Review Checklist
|
||||
|
||||
### General
|
||||
- [ ] Logic mudah dibaca dan dipahami
|
||||
- [ ] Tidak ada dead code atau file `.txt` backup yang tersisa
|
||||
- [ ] Error handling ada di semua `$fetch` / `useFetch` call
|
||||
- [ ] Tidak ada hardcoded IP/URL (gunakan `.env` + `runtimeConfig`)
|
||||
- [ ] Tidak ada `console.log` di high-frequency path (WebSocket handler, polling loop)
|
||||
|
||||
### Vue 3 + TypeScript
|
||||
- [ ] `<script setup>` digunakan (Composition API)
|
||||
- [ ] Props & emits memiliki type yang jelas
|
||||
- [ ] Minimal penggunaan `any` type (justified jika ada)
|
||||
- [ ] Composables dipakai untuk logic reusable (`composables/use*.ts`)
|
||||
- [ ] Reactive data menggunakan `ref()` / `reactive()` dengan benar
|
||||
- [ ] `watch` / `computed` digunakan daripada manual mutation
|
||||
|
||||
### Nuxt 3 Specific
|
||||
- [ ] Data fetching pakai `$fetch` / `useFetch` / `useAsyncData`
|
||||
- [ ] Middleware auth terpasang di route yang memerlukan login
|
||||
- [ ] Server routes (proxy) mengikuti konvensi `server/routes/[prefix]/[...].ts`
|
||||
- [ ] Internal API mengikuti konvensi `server/api/[resource].[method].ts`
|
||||
- [ ] Environment variables diakses via `useRuntimeConfig()`
|
||||
|
||||
### Pinia Store
|
||||
- [ ] Store menggunakan `defineStore()` dengan naming `use[Name]Store`
|
||||
- [ ] State yang perlu persisten menggunakan `pinia-plugin-persistedstate`
|
||||
- [ ] Key persisted state unik per loket/klinik (hindari konflik antar instance)
|
||||
- [ ] `allPatients` tetap single source of truth (tidak duplikasi state)
|
||||
|
||||
### WebSocket
|
||||
- [ ] Client ID deterministic (bukan random) untuk targetable messaging
|
||||
- [ ] Auto-reconnect logic aktif
|
||||
- [ ] Polling fallback 30 detik sebagai safety net
|
||||
- [ ] Event handler tidak melakukan full re-render (surgical update)
|
||||
|
||||
---
|
||||
|
||||
## 6. Standar Kode
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| Tipe | Konvensi | Contoh (Aktual) |
|
||||
|------|----------|-----------------|
|
||||
| Page (Vue) | PascalCase | `Dashboard.vue`, `AdminLoket.vue` |
|
||||
| Component | PascalCase | `PatientCard.vue`, `QueueActionsCard.vue` |
|
||||
| Composable | camelCase + `use` prefix | `useWebSocket.ts`, `useQueue.js` |
|
||||
| Store (Pinia) | camelCase + `Store` suffix | `queueStore.js`, `clinicStore.js` |
|
||||
| Middleware | camelCase | `auth.ts`, `permissions.ts` |
|
||||
| Type/Interface | PascalCase | `User`, `Permission`, `ApiResponse<T>` |
|
||||
| Constants | SCREAMING_SNAKE | `API_BASE_URL`, `WS_API_URL` |
|
||||
| CSS Class | kebab-case | `.patient-card`, `.queue-actions` |
|
||||
| Server API | `[resource].[method].ts` | `permission.get.ts`, `validate-token.post.ts` |
|
||||
| Server Proxy Route | `[...].ts` di folder prefix | `server/routes/visit-api/[...].ts` |
|
||||
|
||||
### File Organization Rules
|
||||
|
||||
```
|
||||
# ✅ Good — composable terpisah untuk concern berbeda
|
||||
composables/
|
||||
useAuth.ts ← Authentication logic
|
||||
useWebSocket.ts ← WebSocket connection management
|
||||
useQueue.js ← Queue business logic
|
||||
useCheckIn.ts ← Check-in flow
|
||||
useThermalPrint.ts ← Thermal printer integration
|
||||
|
||||
# ✅ Good — komponen terorganisir per fitur
|
||||
components/
|
||||
common/ ← Reusable (AppSnackbar, Avatar, PageHeader)
|
||||
layout/ ← Layout (SideBar, ProfileMenu)
|
||||
features/
|
||||
queue/ ← PatientCard, CurrentPatientCard, QueueActionsCard
|
||||
antrean/ ← Komponen anjungan
|
||||
master/ ← Komponen setting master data
|
||||
monitoring/ ← Komponen monitoring pasien
|
||||
|
||||
# ❌ Bad — logic besar langsung di <script setup> page
|
||||
pages/AdminLoket.vue ← Jangan taruh >100 baris logic di sini, extract ke composable
|
||||
```
|
||||
|
||||
### Code Pattern — Composable
|
||||
|
||||
```typescript
|
||||
// ✅ Good — typed composable dengan error handling
|
||||
export function useClinicAPI() {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
async function fetchClinics(): Promise<Clinic[]> {
|
||||
try {
|
||||
const data = await $fetch('/klinik-api/clinics')
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch clinics:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return { fetchClinics }
|
||||
}
|
||||
|
||||
// ❌ Bad — untyped, no error handling
|
||||
export function useClinicAPI() {
|
||||
async function fetchClinics() {
|
||||
const data = await $fetch('/klinik-api/clinics') // bisa crash
|
||||
return data
|
||||
}
|
||||
return { fetchClinics }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD Quality Gates
|
||||
|
||||
> **Catatan:** CI/CD belum diimplementasi. Berikut rencana pipeline saat siap.
|
||||
|
||||
```yaml
|
||||
# GitHub Actions — quality checks (planned)
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type Check
|
||||
run: npx nuxi typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint .
|
||||
|
||||
- name: Unit Tests
|
||||
run: npx vitest run --coverage
|
||||
|
||||
- name: Build
|
||||
run: npx nuxt build
|
||||
```
|
||||
|
||||
### Manual Quality Gate (Saat Ini)
|
||||
|
||||
Sebelum merge / deploy, lakukan checklist manual berikut:
|
||||
|
||||
- [ ] `npx nuxi typecheck` — tidak ada error
|
||||
- [ ] `npx eslint .` — tidak ada error
|
||||
- [ ] `npm run test` — semua test pass
|
||||
- [ ] `npm run build` — build sukses tanpa error
|
||||
- [ ] Test manual di browser Chrome di jaringan LAN (`http://10.10.150.175:3000`)
|
||||
- [ ] Verifikasi WebSocket sync antara admin & display screen
|
||||
- [ ] Pastikan Docker build berhasil (`docker compose build`)
|
||||
|
||||
---
|
||||
|
||||
## 8. Bug Severity Matrix
|
||||
|
||||
| Level | Deskripsi | Contoh di Web Antrean | SLA Fix |
|
||||
|-------|-----------|----------------------|---------|
|
||||
| 🔴 Critical | App crash / data loss / security breach | WebSocket down total → antrean tidak sync, Keycloak auth bypass, pasien kehilangan antrean | < 4 jam |
|
||||
| 🟠 High | Fitur utama tidak bisa dipakai | Tombol panggil pasien tidak berfungsi, anjungan tidak bisa generate antrean, QR scanner error | < 1 hari |
|
||||
| 🟡 Medium | Fitur minor terganggu, ada workaround | Thermal print gagal (pasien masih bisa lihat di layar), statistik dashboard delayed | < 3 hari |
|
||||
| 🟢 Low | UI/kosmetik, tidak mengganggu fungsi | Alignment card tidak rapi, warna badge sedikit off, tooltip tidak muncul | Backlog |
|
||||
|
||||
### Known Issues & Mitigasi
|
||||
|
||||
| Issue | Severity | Mitigasi Saat Ini |
|
||||
|-------|----------|-------------------|
|
||||
| WebSocket disconnect saat jaringan LAN tidak stabil | 🔴 | Auto-reconnect + polling fallback 30 detik |
|
||||
| Endpoint 500 menyebabkan request spam | 🟠 | Blacklist endpoint gagal di `doctorStore.js` |
|
||||
| `console.log` verbose di WebSocket handler | 🟢 | Dibersihkan di high-frequency path |
|
||||
| Race condition antar loket | 🟠 | State isolasi per `loketId` + unique storage key |
|
||||
| Mixed JS/TS — beberapa store masih `.js` | 🟢 | Migrasi bertahap ke `.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Debt Tracker
|
||||
|
||||
| Item | Impact | Priority | Plan |
|
||||
|------|--------|----------|------|
|
||||
| Store files masih `.js` (queueStore, clinicStore, dll) | Type safety rendah | 🟡 Medium | Migrasi bertahap ke TypeScript |
|
||||
| `queueStore.js` terlalu besar (141KB) | Sulit maintain & test | 🟠 High | Pecah menjadi sub-stores per concern |
|
||||
| Tidak ada test coverage saat ini | Regresi tidak terdeteksi | 🟠 High | Mulai dari composable kritis |
|
||||
| CI/CD belum ada | Manual quality gate | 🟡 Medium | Setup GitHub Actions |
|
||||
| Beberapa file backup (`.txt`, `old_*.vue`) masih ada | Noise di codebase | 🟢 Low | Cleanup & gitignore |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial QMD — dibuat berdasarkan kondisi project aktual |
|
||||
+25
-220
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<v-app id="inspire">
|
||||
<SideBar
|
||||
:items="filteredNavItems"
|
||||
v-model:drawer="drawer"
|
||||
:rail="rail"
|
||||
@toggle-rail="rail = !rail"
|
||||
/>
|
||||
<SideBar
|
||||
:items="filteredNavItems"
|
||||
v-model:drawer="drawer"
|
||||
:rail="rail"
|
||||
@toggle-rail="rail = !rail"
|
||||
/>
|
||||
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -22,7 +20,7 @@ import { useNavItemsStore } from '~/stores/navItems1';
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'permissions']
|
||||
middleware: ['auth', 'checkPageAccess']
|
||||
})
|
||||
|
||||
// State for controlling the sidebar
|
||||
@@ -32,219 +30,26 @@ const rail = ref(true);
|
||||
const navItemsStore = useNavItemsStore();
|
||||
const { user, checkAuth } = useAuth();
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
// Navigation will be filtered via navItemsStore using the new hakAkses system
|
||||
|
||||
interface BackendPermission {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface PermissionResponse {
|
||||
message?: string;
|
||||
data?: BackendPermission[];
|
||||
meta?: {
|
||||
count: number;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cache for API permissions
|
||||
const apiPermissions = ref<BackendPermission[]>([]);
|
||||
const currentUserRoles = ref<string[]>([]);
|
||||
const currentUserGroups = ref<string[]>([]);
|
||||
|
||||
// Get current user data with roles and groups
|
||||
const fetchCurrentUserData = async () => {
|
||||
try {
|
||||
const userData = await $fetch('/api/users/current');
|
||||
currentUserRoles.value = [
|
||||
...(userData.realmRoles || []),
|
||||
...(userData.roles || []),
|
||||
];
|
||||
|
||||
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
|
||||
const groups: string[] = [];
|
||||
(userData.groups || []).forEach((g: string) => {
|
||||
const parts = g.split('/').filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
groups.push(parts[1]); // Get second part as group name
|
||||
} else if (parts.length === 1) {
|
||||
groups.push(parts[0]);
|
||||
}
|
||||
});
|
||||
currentUserGroups.value = groups;
|
||||
|
||||
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
|
||||
} catch (error) {
|
||||
console.error('Error fetching current user data:', error);
|
||||
return { roles: [], groups: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API (only for nav filtering, not for saving)
|
||||
// Saving is now handled by permissions middleware
|
||||
const fetchPermissionsFromAPI = async () => {
|
||||
const { roles, groups } = await fetchCurrentUserData();
|
||||
|
||||
if (roles.length === 0 || groups.length === 0) {
|
||||
console.warn('No roles or groups found for current user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first role and first group (or combine as needed)
|
||||
const primaryRole = roles[0] || '';
|
||||
const primaryGroup = groups[0] || '';
|
||||
|
||||
if (!primaryRole || !primaryGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<PermissionResponse>('/api/permission', {
|
||||
query: {
|
||||
roles: primaryRole,
|
||||
groups: primaryGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
apiPermissions.value = response.data;
|
||||
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions from API:', error);
|
||||
// Fallback to local storage if API fails
|
||||
apiPermissions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNavItems = computed(() => {
|
||||
// If no API permissions, check local storage as fallback
|
||||
if (apiPermissions.value.length === 0) {
|
||||
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
|
||||
const roleCandidates = [
|
||||
...(user.value?.roles || []),
|
||||
...(user.value?.realm_access?.roles || []),
|
||||
].map((role) => role.toLowerCase());
|
||||
|
||||
const localPermission = hakAksesData.value.find((item) =>
|
||||
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
|
||||
);
|
||||
|
||||
if (localPermission) {
|
||||
const permissionMap = new Map(
|
||||
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
|
||||
);
|
||||
|
||||
const applyFilter = (items: NavItem[]): NavItem[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const menuPerm = permissionMap.get(item.name.toLowerCase());
|
||||
const filteredChildren = item.children ? applyFilter(item.children) : [];
|
||||
const allowThis = menuPerm ? menuPerm.canAccess : false;
|
||||
const hasChildren = filteredChildren.length > 0;
|
||||
|
||||
if (!allowThis && !hasChildren) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(hasChildren ? { children: filteredChildren } : {}),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
return applyFilter(navItemsStore.navItems);
|
||||
}
|
||||
|
||||
// If no permissions found, show all items
|
||||
return navItemsStore.navItems;
|
||||
}
|
||||
|
||||
// Use API permissions to filter
|
||||
const permissionMap = new Map(
|
||||
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
|
||||
);
|
||||
|
||||
// Mapping untuk pagename dari API ke nama menu di sidebar
|
||||
const pagenameToMenuMapping: Record<string, string[]> = {
|
||||
'halaman utama': ['dashboard', 'halaman utama'],
|
||||
'pengaturan': ['master data'],
|
||||
'halaman': ['master data'],
|
||||
'dashboard': ['dashboard'],
|
||||
};
|
||||
|
||||
const applyFilter = (items: NavItem[]): NavItem[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
// Try to match by pagename or menu name
|
||||
let perm = permissionMap.get(item.name.toLowerCase());
|
||||
|
||||
// If no direct match, try fuzzy matching
|
||||
if (!perm) {
|
||||
perm = Array.from(permissionMap.values()).find(p => {
|
||||
const pagenameLower = p.pagename?.toLowerCase() || '';
|
||||
const menuNameLower = item.name.toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (pagenameLower === menuNameLower) return true;
|
||||
|
||||
// Contains match
|
||||
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check mapping
|
||||
const mappedMenus = pagenameToMenuMapping[pagenameLower];
|
||||
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const filteredChildren = item.children ? applyFilter(item.children) : [];
|
||||
const allowThis = perm ? (perm.active || perm.read) : false;
|
||||
const hasChildren = filteredChildren.length > 0;
|
||||
|
||||
// If permission allows and has children, show item with filtered children
|
||||
// If permission allows but no children, show item
|
||||
// If no permission but has allowed children, show item with children
|
||||
if (!allowThis && !hasChildren) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(hasChildren ? { children: filteredChildren } : {}),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
return applyFilter(navItemsStore.navItems);
|
||||
});
|
||||
const filteredNavItems = computed(() => navItemsStore.filteredNavItems);
|
||||
|
||||
onMounted(async () => {
|
||||
await checkAuth();
|
||||
await fetchPermissionsFromAPI();
|
||||
if (user.value) {
|
||||
await navItemsStore.refreshNavItems();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for user changes to refresh navItems
|
||||
watch(() => user.value, async (newUser) => {
|
||||
if (newUser) {
|
||||
await navItemsStore.refreshNavItems();
|
||||
} else {
|
||||
// Optionally reset navItems when logged out
|
||||
navItemsStore.filteredNavItems = [];
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+3
-5
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
<v-main>
|
||||
<slot />
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
+42
-11
@@ -10,9 +10,8 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
|
||||
const bypassFlag = sessionStorage.getItem('bypassRootRedirect');
|
||||
if (bypassFlag === 'true' && to.path === '/') {
|
||||
console.log('🔑 Bypass flag detected - allowing root access');
|
||||
// Clear the flag immediately to prevent future bypasses
|
||||
sessionStorage.removeItem('bypassRootRedirect');
|
||||
return; // Allow access without any redirect
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,29 +27,61 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip auth check if it's the development server side render pass.
|
||||
if (process.server && process.env.NODE_ENV === 'development') {
|
||||
console.log('⏭️ Skipping intensive check on server-side during development');
|
||||
useAuth();
|
||||
// On server-side, skip auth check - let client handle it
|
||||
if (process.server) {
|
||||
console.log('⏭️ Server-side: Skipping auth check (will verify on client)');
|
||||
return;
|
||||
}
|
||||
|
||||
// CLIENT-SIDE ONLY from here
|
||||
|
||||
// Check for the authentication signal from a successful login redirect
|
||||
const isAuthRedirect: boolean = to.query.authenticated === 'true';
|
||||
const isAuthRedirect = to.query.authenticated === 'true';
|
||||
|
||||
if (isAuthRedirect) {
|
||||
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
console.log('⏳ Processing new login session...');
|
||||
|
||||
// Give browser time to process the cookie from redirect
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const { checkAuth } = useAuth();
|
||||
console.log('🔍 Checking authentication after redirect...');
|
||||
|
||||
let user = await checkAuth();
|
||||
|
||||
// Retry up to 3 times if cookie not available yet
|
||||
let retries = 0;
|
||||
while (!user && retries < 3) {
|
||||
console.log(`⚠️ Cookie not available yet, retry ${retries + 1}/3...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
user = await checkAuth();
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
console.log('✅ User authenticated after redirect:', user.name || user.email);
|
||||
// Remove query parameter and allow access
|
||||
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
} else {
|
||||
console.log('❌ No session after retries, redirecting to login');
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
} catch (authError) {
|
||||
console.error('❌ Auth check failed after redirect:', authError);
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
}
|
||||
|
||||
// Normal auth check for protected routes
|
||||
try {
|
||||
const { checkAuth } = useAuth();
|
||||
console.log('🔍 Checking authentication status using useAuth...');
|
||||
console.log('🔍 Checking authentication status...');
|
||||
|
||||
const user = await checkAuth();
|
||||
|
||||
if (user) {
|
||||
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email);
|
||||
console.log('✅ User is authenticated:', user.name || user.email);
|
||||
return;
|
||||
} else {
|
||||
console.log('❌ No valid session found, redirecting to login');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// middleware/checkPageAccess.ts
|
||||
// Middleware to check if user has access to the page based on hakAkses
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
// Skip check for public pages
|
||||
const publicPaths = ['/LoginPage', '/auth/login', '/index-legacy'];
|
||||
|
||||
// index.vue is the debug dashboard, let's keep it accessible for now as requested
|
||||
if (to.path === '/' || publicPaths.includes(to.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On server-side, skip access check - let client handle it
|
||||
// This matches auth.ts behavior and prevents SSR failures when cookie context is missing
|
||||
if (process.server) {
|
||||
console.log('⏭️ Server-side: Skipping page access check (will verify on client)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Import useAuth and useHakAkses
|
||||
const { user, checkAuth } = useAuth();
|
||||
const { getAllowedPages } = useHakAkses();
|
||||
|
||||
// If user not loaded, try to load
|
||||
if (!user.value) {
|
||||
await checkAuth();
|
||||
}
|
||||
|
||||
// If still not authenticated, redirect to login
|
||||
if (!user.value) {
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
|
||||
try {
|
||||
const allowedPages = await getAllowedPages();
|
||||
|
||||
const targetPath = to.path.endsWith('/') && to.path.length > 1 ? to.path.slice(0, -1) : to.path;
|
||||
const targetPathLower = targetPath.toLowerCase();
|
||||
const toPathLower = to.path.toLowerCase();
|
||||
|
||||
// Check if user has access to this page
|
||||
// We also check against the raw path just in case, case-insensitive
|
||||
const isAllowed = allowedPages.some(path => {
|
||||
const normalizedAllowed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
|
||||
const normalizedAllowedLower = normalizedAllowed.toLowerCase();
|
||||
const pathLower = path.toLowerCase();
|
||||
return normalizedAllowedLower === targetPathLower || pathLower === toPathLower;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
console.warn(`Access denied to ${to.path}. User allowed pages:`, allowedPages);
|
||||
|
||||
// Redirect to first allowed page if available, else stay/error
|
||||
if (allowedPages.length > 0) {
|
||||
// If dashboard is allowed, go there, else go to the first allowed one
|
||||
const dashboardPath = allowedPages.find(p => p === '/' || p === '/dashboard');
|
||||
return navigateTo(dashboardPath || allowedPages[0]);
|
||||
} else {
|
||||
// No access to any page - technically this shouldn't happen if user has roles
|
||||
console.error('User has roles but no allowed pages found in configuration.');
|
||||
// For now, allow root as fallback since index.vue is kept
|
||||
if (to.path === '/') return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking page access:', error);
|
||||
// On error, we might want to allow or block. Let's allow but log.
|
||||
return;
|
||||
}
|
||||
});
|
||||
+2
-1
@@ -27,8 +27,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
try {
|
||||
console.log('🔍 Checking if user is already authenticated...');
|
||||
|
||||
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
|
||||
// The $fetch will automatically send the new user_session cookie
|
||||
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
|
||||
const session = await $fetch<{ user: any } | null>('/api/auth/session', { headers }).catch(() => null);
|
||||
|
||||
if (session && session.user) {
|
||||
console.log('✅ User already authenticated, redirecting to dashboard');
|
||||
|
||||
@@ -231,9 +231,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// DISABLED: Auto-fetch permissions is now disabled
|
||||
// Permissions should only be fetched manually via button click in HakAkses page
|
||||
// Run async permission sync (non-blocking)
|
||||
// This will only run once per session due to sessionStorage check
|
||||
fetchAndSavePermissions().catch(err => {
|
||||
console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
|
||||
});
|
||||
// fetchAndSavePermissions().catch(err => {
|
||||
// console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
|
||||
// });
|
||||
});
|
||||
+72
-45
@@ -3,12 +3,11 @@ import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-05-15",
|
||||
devtools: {
|
||||
enabled: true,
|
||||
enabled: process.env.ENABLE_DEVTOOLS === 'true',
|
||||
timeline: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
meta: [
|
||||
@@ -16,47 +15,38 @@ export default defineNuxtConfig({
|
||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
|
||||
],
|
||||
link: [
|
||||
{ rel: "icon", type: "image/x-icon", href: "/love%20logo%20biru%20small.ico" },
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
modules: [
|
||||
// "@nuxt/content",
|
||||
"@nuxt/eslint",
|
||||
"@nuxt/fonts",
|
||||
"@nuxt/icon",
|
||||
"@nuxt/image",
|
||||
"@nuxt/scripts",
|
||||
"@nuxt/test-utils",
|
||||
"@nuxt/ui",
|
||||
"@pinia/nuxt",
|
||||
"@vesp/nuxt-fontawesome",
|
||||
"@nuxtjs/google-fonts",
|
||||
async (_options, nuxt) => {
|
||||
nuxt.hooks.hook("vite:extendConfig", async (config) => {
|
||||
modules: [// "@nuxt/content",
|
||||
"@nuxt/eslint", "@nuxt/fonts", "@nuxt/icon", "@nuxt/image", "@nuxt/scripts", "@nuxt/test-utils", "@nuxt/ui", "@pinia/nuxt", "@vesp/nuxt-fontawesome", "@nuxtjs/google-fonts", async (_options, nuxt) => {
|
||||
nuxt.hooks.hook("vite:extendConfig", async (config) => {
|
||||
// @ts-expect-error
|
||||
config.plugins.push(vuetify({ autoImport: true }));
|
||||
|
||||
// Add HTTPS plugin
|
||||
try {
|
||||
// @ts-ignore
|
||||
const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl');
|
||||
// @ts-expect-error
|
||||
config.plugins.push(vuetify({ autoImport: true }));
|
||||
|
||||
// Add HTTPS plugin
|
||||
try {
|
||||
// @ts-ignore
|
||||
const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl');
|
||||
// @ts-expect-error
|
||||
config.plugins.push(basicSsl());
|
||||
// @ts-expect-error
|
||||
config.server = config.server || {};
|
||||
// @ts-expect-error
|
||||
config.server.https = true;
|
||||
// @ts-expect-error
|
||||
config.server.host = '10.10.150.175';
|
||||
// @ts-expect-error
|
||||
config.server.port = 3001;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load HTTPS plugin:', e);
|
||||
}
|
||||
});
|
||||
},
|
||||
],
|
||||
config.plugins.push(basicSsl());
|
||||
// @ts-expect-error
|
||||
config.server = config.server || {};
|
||||
// @ts-expect-error
|
||||
config.server.https = true;
|
||||
// @ts-expect-error
|
||||
config.server.host = '10.10.150.114';
|
||||
// @ts-expect-error
|
||||
config.server.port = 3000;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load HTTPS plugin:', e);
|
||||
}
|
||||
});
|
||||
}, "nuxt-charts"],
|
||||
|
||||
fontawesome: {
|
||||
icons: {
|
||||
@@ -77,10 +67,27 @@ export default defineNuxtConfig({
|
||||
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
|
||||
keycloakLogoutUri: process.env.KEYCLOAK_LOGOUT_URI, // Optional: custom logout URI
|
||||
postLogoutRedirectUri: process.env.POST_LOGOUT_REDIRECT_URI, // Optional: custom post-logout redirect URI
|
||||
|
||||
// Session and Oauth durations
|
||||
sessionDurationHours: parseInt(process.env.SESSION_DURATION_HOURS || '1', 10),
|
||||
oauthStateDurationMinutes: parseInt(process.env.OAUTH_STATE_DURATION_MINUTES || '10', 10),
|
||||
|
||||
// External API
|
||||
externalApiBaseUrl: process.env.EXTERNAL_API_BASE_URL || 'http://10.10.123.135:8084',
|
||||
externalApiTimeout: parseInt(process.env.EXTERNAL_API_TIMEOUT || '10000', 10),
|
||||
proxyClientOrigin: process.env.PROXY_CLIENT_ORIGIN || 'http://10.10.150.175:3000',
|
||||
proxyTargetHostFallback: process.env.PROXY_TARGET_HOST_FALLBACK || '10.10.123.135:8084',
|
||||
proxyTargetHostKlinikFallback: process.env.PROXY_TARGET_HOST_KLINIK_FALLBACK || '10.10.123.140:8089',
|
||||
|
||||
public: {
|
||||
authUrl: process.env.AUTH_ORIGIN,
|
||||
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
|
||||
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
|
||||
wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws',
|
||||
verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1',
|
||||
externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -93,11 +100,31 @@ export default defineNuxtConfig({
|
||||
"@mdi/font/css/materialdesignicons.min.css",
|
||||
"~/assets/scss/main.scss",
|
||||
],
|
||||
devServer: (() => {
|
||||
const hostEnv = process.env.HOST || 'localhost';
|
||||
|
||||
// Parse HOST if it's a full URL (e.g., "http://10.10.150.175:3000")
|
||||
let host = hostEnv;
|
||||
let port = 3000;
|
||||
|
||||
try {
|
||||
const url = new URL(hostEnv);
|
||||
host = url.hostname;
|
||||
port = url.port ? parseInt(url.port, 10) : 3000;
|
||||
} catch {
|
||||
// If HOST is not a URL, use it as-is (e.g., "10.10.150.175" or "localhost")
|
||||
host = hostEnv;
|
||||
}
|
||||
|
||||
return {
|
||||
port: port,
|
||||
host: host,
|
||||
https: false, // Enable HTTPS
|
||||
allowedHosts: ["localhost", "localhost.dev", "10.10.150.175","antrean.dev.rssa.id"]
|
||||
};
|
||||
})(),
|
||||
|
||||
|
||||
devServer: {
|
||||
port: 3001,
|
||||
host: '10.10.150.175'
|
||||
},
|
||||
|
||||
vite: {
|
||||
css: {
|
||||
@@ -116,4 +143,4 @@ export default defineNuxtConfig({
|
||||
noExternal: ["vuetify"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
Generated
+6939
-2300
File diff suppressed because it is too large
Load diff
+18
-7
@@ -4,14 +4,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"_command_dev": "nuxt dev -o --host --port 3001",
|
||||
"_command_dev2": "nuxt dev -o --port 3001",
|
||||
"_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001",
|
||||
"_command_dev": "nuxt dev -o --host --port 3000",
|
||||
"_command_dev2": "nuxt dev -o --port 3000",
|
||||
"dev": "nuxt dev -o",
|
||||
"dev:https": "nuxt dev -o --host localhost --port 3001",
|
||||
"dev:https": "nuxt dev -o --host 0.0.0.0 --port 3000",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:run": "cypress run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
@@ -33,23 +36,31 @@
|
||||
"eslint": "^9.32.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"nuxt": "^3.17.7",
|
||||
"nuxt-charts": "^2.0.0",
|
||||
"nuxt-qrcode": "^0.4.8",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-carousel": "^0.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/google-fonts": "^3.2.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"cypress": "^15.9.0",
|
||||
"happy-dom": "^20.5.0",
|
||||
"sass": "^1.93.3",
|
||||
"sass-embedded": "^1.89.2",
|
||||
"vite-plugin-vuetify": "^2.1.2",
|
||||
"vitest": "^3.2.4",
|
||||
"vuetify": "^3.9.3"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user